Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on ProviderValidationDate so a provider's signed estimate leaves their list; PostIt refreshes the list on navigation pop-back. - Title renames: "Devis en attente de signature fournisseur/client" on both the provider and client sides (HomePage, VM, tests). - Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent, Bill/Estimate templates, _ViewImports). Program.cs wires AddControllersWithViews + SiteSettings; FrontOfficeApiController injects IRazorViewEngine (cast to IViewEngine) with optional deps and exposes estimate.tex/estimate.pdf endpoints keyed by query id. - PDF generation drives through the Estimate_pdf view (side-effect render) and compiles via the system lualatex, fed TeX on stdin (no intermediate .tex), PDF to the bills dir, aux artifacts cleaned so only the .pdf persists. Resolve the bills dir to an absolute path so lualatex's -output-directory and working directory agree regardless of the host cwd (fixes "I can't write on file estimate-1.log"). - PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync; Télécharger PDF/TeX buttons on the validation and command-detail pages via a StorageProvider save helper. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
70a123b840
commit
97d0584292
41 changed files with 1036 additions and 290 deletions
|
|
@ -17,7 +17,7 @@ public class EstimateListPageViewModelTests
|
|||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
||||
Assert.Equal("Mes devis à valider", vm.Title);
|
||||
Assert.Equal("Devis en attente de signature client", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
// Most recent first
|
||||
Assert.Equal(2, vm.Estimates[0].Id);
|
||||
|
|
@ -35,7 +35,7 @@ public class EstimateListPageViewModelTests
|
|||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
||||
Assert.Equal("Mes devis en attente", vm.Title);
|
||||
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ using System.Net.Http;
|
|||
using PostIt.Controls;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the
|
||||
/// signature pad + perspective into the right
|
||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
|
||||
/// signature pads (provider + client) 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,
|
||||
|
|
@ -46,9 +47,10 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
DrawOneStroke(pad);
|
||||
var proPad = new SignaturePadControl();
|
||||
var clientPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, clientPad);
|
||||
DrawOneStroke(proPad); // provider signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
|
|
@ -60,6 +62,28 @@ public class EstimateValidationPageViewModelTests
|
|||
Assert.NotNull(body.Strokes);
|
||||
Assert.NotEmpty(body.Strokes);
|
||||
Assert.Equal(10_000, body.CoordinateMax);
|
||||
// The provider perspective declares the Pro side, so the
|
||||
// server stores the signature under the provider's author
|
||||
// regardless of role inference.
|
||||
Assert.Equal(SignatureType.Pro, body.SignatureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_from_client_perspective_declares_client_side()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(new SignaturePadControl(), cliPad);
|
||||
DrawOneStroke(cliPad); // client signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
||||
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -70,7 +94,7 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl());
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
|
||||
|
||||
await vm.RejectAsync();
|
||||
|
||||
|
|
@ -88,7 +112,7 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl()); // empty pad
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
|
||||
|
||||
await vm.ValidateAsync(); // no throw: returns before GoBack
|
||||
|
||||
|
|
@ -104,19 +128,19 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
var proPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, new SignaturePadControl());
|
||||
|
||||
// No CommandId ⇒ cannot validate even after signing.
|
||||
DrawOneStroke(pad);
|
||||
DrawOneStroke(proPad);
|
||||
Assert.False(vm.ValidateCommand.CanExecute(null));
|
||||
|
||||
var vm2 = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var pad2 = new SignaturePadControl();
|
||||
vm2.Attach(pad2);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
vm2.Attach(proPad2, new SignaturePadControl());
|
||||
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
||||
DrawOneStroke(pad2);
|
||||
DrawOneStroke(proPad2);
|
||||
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
|
|
@ -134,48 +158,67 @@ public class EstimateValidationPageViewModelTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_signature_renders_loaded_strokes_in_pad()
|
||||
public void Only_the_author_pad_is_writable()
|
||||
{
|
||||
var strokes = new[] { 1, 5000, 5000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.Signatures = new List<EstimateSignatureDto>
|
||||
{
|
||||
new() { Type = 0, Strokes = strokes, CoordinateMax = 10_000 },
|
||||
};
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
var proVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
proVm.Attach(proPad, cliPad);
|
||||
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
|
||||
Assert.True(cliPad.IsReadOnly);
|
||||
|
||||
var snap = pad.Snapshot();
|
||||
Assert.False(snap.IsEmpty);
|
||||
Assert.Equal(strokes, snap.Strokes);
|
||||
Assert.True(vm.HasSignature);
|
||||
var cliVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
var cliPad2 = new SignaturePadControl();
|
||||
cliVm.Attach(proPad2, cliPad2);
|
||||
Assert.True(proPad2.IsReadOnly);
|
||||
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_signature_picks_side_by_perspective()
|
||||
public void Load_existing_signatures_renders_both_sides_in_their_pads()
|
||||
{
|
||||
var proStrokes = new[] { 1, 1000, 1000 };
|
||||
var cliStrokes = new[] { 1, 9000, 9000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.Signatures = new List<EstimateSignatureDto>
|
||||
{
|
||||
new() { Type = 0, Strokes = proStrokes },
|
||||
new() { Type = 1, Strokes = cliStrokes },
|
||||
};
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
|
||||
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
|
||||
|
||||
// Client perspective: both pads show the stored signatures, but
|
||||
// only the client pad is writable.
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.True(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_pro_signature_renders_in_pro_pad_only()
|
||||
{
|
||||
var strokes = new[] { 1, 5000, 5000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
|
||||
var proPad = new SignaturePadControl();
|
||||
var proVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
proVm.Attach(proPad);
|
||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
||||
|
||||
var cliPad = new SignaturePadControl();
|
||||
var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
||||
cliVm.Attach(cliPad);
|
||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(strokes, proPad.Snapshot().Strokes);
|
||||
Assert.True(cliPad.Snapshot().IsEmpty);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.False(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
|
|
|
|||
|
|
@ -84,6 +84,34 @@ public class FrontOfficeApiClientTests
|
|||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimatePdfAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.pdf",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimateTexAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.tex",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
|
|
@ -116,6 +144,14 @@ public class FrontOfficeApiClientTests
|
|||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = null;
|
||||
return Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
|
|||
set => SetValue(CaptureAreaProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true the control ignores pointer input — it only displays
|
||||
/// strokes loaded into its buffer (e.g. the other party's
|
||||
/// signature, which must be shown but not editable). <see cref="Load"/>
|
||||
/// and <see cref="Clear"/> still work.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> IsReadOnlyProperty =
|
||||
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => GetValue(IsReadOnlyProperty);
|
||||
set => SetValue(IsReadOnlyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captured strokes in wire form. Exposed as a read-only view
|
||||
/// over the internal buffer. The buffer only mutates on the UI
|
||||
|
|
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
|
|||
|
||||
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
||||
e.Pointer.Capture(CaptureArea);
|
||||
_capturing = true;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ public partial class SignaturePadView : UserControl
|
|||
private const double StrokeThickness = 2.0;
|
||||
private const double CoordinateMax = 10_000.0;
|
||||
|
||||
// Last size the ink layer was painted at. LayoutUpdated repaints
|
||||
// only when this changes, so adding/removing polyline children
|
||||
// (which invalidates layout) does not loop.
|
||||
private Size _lastPaintedSize;
|
||||
|
||||
public static readonly StyledProperty<double> CaptureWidthProperty =
|
||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
||||
|
||||
|
|
@ -71,6 +76,19 @@ public partial class SignaturePadView : UserControl
|
|||
ApplyFrameSize();
|
||||
|
||||
InnerPad.RedrawRequested += OnPadRedrawRequested;
|
||||
// Repaint once layout completes so an ink buffer loaded before
|
||||
// the first arrange (e.g. an existing signature on reopen)
|
||||
// still renders without waiting for a pointer event. The
|
||||
// handler repaints only on a size change to avoid a layout
|
||||
// loop (child changes invalidate layout → LayoutUpdated).
|
||||
LayoutUpdated += OnLayoutUpdated;
|
||||
}
|
||||
|
||||
private void OnLayoutUpdated(object? sender, EventArgs e)
|
||||
{
|
||||
var size = EffectivePadSize();
|
||||
if (size != _lastPaintedSize)
|
||||
Repaint();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
|
|
@ -101,14 +119,34 @@ public partial class SignaturePadView : UserControl
|
|||
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
||||
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
|
||||
|
||||
/// <summary>
|
||||
/// The effective ink surface size: the arranged bounds when
|
||||
/// available, otherwise the explicitly sized
|
||||
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
|
||||
/// latter covers a repaint triggered before the first layout pass
|
||||
/// (e.g. loading an existing signature on reopen), when Bounds is
|
||||
/// still empty.
|
||||
/// </summary>
|
||||
private Size EffectivePadSize()
|
||||
{
|
||||
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
|
||||
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
|
||||
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
|
||||
return default;
|
||||
return new Size(w, h);
|
||||
}
|
||||
|
||||
private void Repaint()
|
||||
{
|
||||
if (InkLayer is null) return;
|
||||
|
||||
var size = EffectivePadSize();
|
||||
if (size == default) return;
|
||||
_lastPaintedSize = size;
|
||||
|
||||
InkLayer.Children.Clear();
|
||||
var w = PadFrame.Bounds.Width;
|
||||
var h = PadFrame.Bounds.Height;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
var w = size.Width;
|
||||
var h = size.Height;
|
||||
|
||||
var pending = InnerPad.PendingStroke;
|
||||
var strokes = InnerPad.Strokes;
|
||||
|
|
|
|||
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PostIt.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Saves a downloaded byte payload to a file chosen by the user through
|
||||
/// the platform save picker. This is the first file-save (as opposed to
|
||||
/// file-open) helper in PostIt; the open picker is already used for blog
|
||||
/// attachment and avatar uploads
|
||||
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
|
||||
///
|
||||
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
|
||||
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
|
||||
/// both the desktop and single-view lifetimes.</para>
|
||||
/// </summary>
|
||||
public static class FileSaveHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Prompt the user to choose a destination and write
|
||||
/// <paramref name="content"/> to it. Returns false when the user
|
||||
/// cancels the picker; throws on IO/platform errors so the caller
|
||||
/// can surface them.
|
||||
/// </summary>
|
||||
public static async Task<bool> SaveAsync(
|
||||
string suggestedName,
|
||||
string defaultExtension,
|
||||
byte[] content,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var view = (Application.Current as App)?.View;
|
||||
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
|
||||
if (topLevel is null)
|
||||
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
|
||||
|
||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Enregistrer sous",
|
||||
DefaultExtension = defaultExtension,
|
||||
SuggestedFileName = suggestedName,
|
||||
FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
|
||||
{
|
||||
Patterns = new[] { $"*.{defaultExtension}" }
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
return false; // user cancelled
|
||||
|
||||
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
|
||||
await stream.WriteAsync(content, ct).ConfigureAwait(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
return "Avatar mis à jour.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
|
||||
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
|
||||
/// which JSON-decodes the response, this returns the raw response
|
||||
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
|
||||
/// contract as <see cref="SetAvatarAsync"/>.
|
||||
/// </summary>
|
||||
public virtual async Task<byte[]> DownloadAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_tokens is null)
|
||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
||||
|
||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var sendOnce = async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
var response = await sendOnce().ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
|
|
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
try
|
||||
{
|
||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
Activity,
|
||||
Performer,
|
||||
Form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
details,
|
||||
IsReadOnly);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace PostIt.ViewModels;
|
|||
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly BillingApiClient _billingClient;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private readonly BillingQueryDetailsDto _details;
|
||||
|
||||
public ActivityInfo Activity { get; }
|
||||
|
|
@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
ActivityUserDisplayItem performer,
|
||||
CommandFormSummary form,
|
||||
BillingApiClient billingClient,
|
||||
FrontOfficeApiClient frontClient,
|
||||
BillingQueryDetailsDto details,
|
||||
bool isReadOnly)
|
||||
{
|
||||
|
|
@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||
IsReadOnly = isReadOnly;
|
||||
|
||||
|
|
@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a LaTeX source
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadTexAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimateTexAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a compiled PDF
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
|
||||
/// <c>texi2pdf</c> on the server host.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadPdfAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimatePdfAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await fetch().ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved
|
||||
? $"Fichier .{ext} enregistré."
|
||||
: "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
||||
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ namespace PostIt.ViewModels;
|
|||
/// </summary>
|
||||
public enum EstimateListPerspective
|
||||
{
|
||||
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
|
||||
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
|
||||
Client,
|
||||
|
||||
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
|
||||
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
|
||||
Provider,
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +40,8 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
|||
public EstimateListPerspective Perspective => _perspective;
|
||||
|
||||
public string Title => _perspective == EstimateListPerspective.Client
|
||||
? "Mes devis à valider"
|
||||
: "Mes devis en attente";
|
||||
? "Devis en attente de signature client"
|
||||
: "Devis en attente de signature fournisseur";
|
||||
|
||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||
? "Point de vue client"
|
||||
|
|
|
|||
|
|
@ -10,27 +10,34 @@ using PostIt.Controls;
|
|||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Validation (accept / reject) of a single estimate, with an optional
|
||||
/// signature. Backs the estimate validation page opened from
|
||||
/// Validation (accept / reject) of a single estimate, with optional
|
||||
/// signatures. Backs the estimate validation page opened from
|
||||
/// <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"/>.
|
||||
/// <para>Two signatures are shown, each in its own pad: the provider's
|
||||
/// (Pro) and the client's (Client). Both are rendered from the estimate
|
||||
/// payload when present, so reopening a partly-signed estimate shows
|
||||
/// what was already captured. Only the pad matching the current
|
||||
/// <see cref="EstimateListPerspective"/> is writable — the server
|
||||
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
|
||||
/// same rule (it resolves the caller's role from the authenticated user
|
||||
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
|
||||
/// the other pad read-only. 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"/>.</para>
|
||||
/// </summary>
|
||||
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly EstimateDto _estimate;
|
||||
private readonly EstimateListPerspective _perspective;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private SignaturePadControl? _pad;
|
||||
private SignaturePadControl? _proPad;
|
||||
private SignaturePadControl? _clientPad;
|
||||
|
||||
public string Title => _estimate.Title;
|
||||
public string Description => _estimate.Description;
|
||||
|
|
@ -44,9 +51,22 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
? "Vous validez en tant que prestataire"
|
||||
: "Vous validez en tant que client";
|
||||
|
||||
/// <summary>
|
||||
/// True when the current user may sign the provider's signature
|
||||
/// (i.e. they opened the list as the provider). The other party's
|
||||
/// pad is read-only.
|
||||
/// </summary>
|
||||
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
|
||||
|
||||
/// <summary>
|
||||
/// True when the current user may sign the client's signature
|
||||
/// (i.e. they opened the list as the client).
|
||||
/// </summary>
|
||||
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of who has already validated this
|
||||
/// estimate, shown above the signature pad.
|
||||
/// estimate, shown above the signature pads.
|
||||
/// </summary>
|
||||
public string ValidationStateText
|
||||
{
|
||||
|
|
@ -78,15 +98,29 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||
|
||||
/// <summary>Stroke/point counts for the writable pad.</summary>
|
||||
[ObservableProperty]
|
||||
public partial int StrokeCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int PointCount { get; set; }
|
||||
|
||||
/// <summary>True when the writable pad holds a signature.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool HasSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasProSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasClientSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ProSignatureHint { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ClientSignatureHint { get; set; } = string.Empty;
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
get => false;
|
||||
|
|
@ -110,58 +144,80 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
}
|
||||
|
||||
/// <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. Also calls
|
||||
/// <see cref="LoadExistingSignature"/> so a previously drawn
|
||||
/// signature — carried on the estimate payload — is rendered back
|
||||
/// into the pad on reopen.
|
||||
/// Bind the page's two signature pads (the inner controls of the
|
||||
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
|
||||
/// the client's pad. Called from the view's code-behind once the
|
||||
/// view is in the visual tree. Marks the non-author pad read-only,
|
||||
/// then renders any signatures already carried on the estimate
|
||||
/// payload.
|
||||
/// </summary>
|
||||
public void Attach(SignaturePadControl pad)
|
||||
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
|
||||
{
|
||||
if (pad is null) throw new ArgumentNullException(nameof(pad));
|
||||
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
|
||||
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
|
||||
Detach();
|
||||
_pad = pad;
|
||||
_pad.RedrawRequested += OnPadRedraw;
|
||||
_proPad = proPad;
|
||||
_clientPad = clientPad;
|
||||
_proPad.IsReadOnly = !CanWritePro;
|
||||
_clientPad.IsReadOnly = !CanWriteClient;
|
||||
_proPad.RedrawRequested += OnPadRedraw;
|
||||
_clientPad.RedrawRequested += OnPadRedraw;
|
||||
LoadExistingSignatures();
|
||||
RefreshFromPad();
|
||||
LoadExistingSignature();
|
||||
}
|
||||
|
||||
public void Detach()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
_pad.RedrawRequested -= OnPadRedraw;
|
||||
_pad = null;
|
||||
if (_proPad is not null)
|
||||
_proPad.RedrawRequested -= OnPadRedraw;
|
||||
if (_clientPad is not null)
|
||||
_clientPad.RedrawRequested -= OnPadRedraw;
|
||||
_proPad = null;
|
||||
_clientPad = null;
|
||||
}
|
||||
|
||||
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
|
||||
|
||||
/// <summary>The pad the current user may sign in.</summary>
|
||||
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
|
||||
|
||||
/// <summary>
|
||||
/// Render the signature already stored for this side of the
|
||||
/// estimate back into the pad. The strokes are carried on the
|
||||
/// estimate payload (<see cref="EstimateDto.Signatures"/>); the
|
||||
/// side is chosen by the current <see cref="_perspective"/> — the
|
||||
/// provider reopens to see their "Pro" signature, the client their
|
||||
/// "Client" signature. No network round-trip: the payload is used
|
||||
/// as-is, in the same wire format PostIt captured.
|
||||
/// Render the signatures already stored for this estimate back into
|
||||
/// their pads. The strokes are carried on the estimate payload as
|
||||
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
|
||||
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
|
||||
/// its own side directly, in the same wire format PostIt captured.
|
||||
/// No network round-trip.
|
||||
/// </summary>
|
||||
public void LoadExistingSignature()
|
||||
public void LoadExistingSignatures()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
// SignatureType.Pro = 0, SignatureType.Client = 1 (server enum).
|
||||
int wanted = _perspective == EstimateListPerspective.Provider ? 0 : 1;
|
||||
var sig = _estimate.Signatures?.FirstOrDefault(s => s.Type == wanted);
|
||||
if (sig?.Strokes is { Length: > 0 } strokes)
|
||||
{
|
||||
_pad.Load(strokes);
|
||||
RefreshFromPad();
|
||||
}
|
||||
LoadSide(_proPad, _estimate.SignaturePro);
|
||||
LoadSide(_clientPad, _estimate.SignatureClient);
|
||||
RefreshFromPad();
|
||||
}
|
||||
|
||||
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
|
||||
{
|
||||
if (pad is null || sig is null) return;
|
||||
if (sig.Strokes is { Length: > 0 } strokes)
|
||||
pad.Load(strokes);
|
||||
}
|
||||
|
||||
private void RefreshFromPad()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
var snap = _pad.Snapshot();
|
||||
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
|
||||
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
|
||||
|
||||
ProSignatureHint = CanWritePro
|
||||
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
|
||||
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
|
||||
ClientSignatureHint = CanWriteClient
|
||||
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
|
||||
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
|
||||
|
||||
var pad = WritablePad;
|
||||
if (pad is null) return;
|
||||
var snap = pad.Snapshot();
|
||||
StrokeCount = snap.StrokeCount;
|
||||
PointCount = snap.PointCount;
|
||||
HasSignature = !snap.IsEmpty;
|
||||
|
|
@ -188,13 +244,14 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
|
||||
return;
|
||||
}
|
||||
if (_pad is null || _pad.Snapshot().IsEmpty)
|
||||
var pad = WritablePad;
|
||||
if (pad is null || pad.Snapshot().IsEmpty)
|
||||
{
|
||||
this.SetWarningStatus("Veuillez signer avant de valider.");
|
||||
return;
|
||||
}
|
||||
|
||||
var snap = _pad.Snapshot();
|
||||
var snap = pad.Snapshot();
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
|
|
@ -203,6 +260,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
Strokes = snap.Strokes,
|
||||
CoordinateMax = SignaturePadData.CoordinateMax,
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
// Declare which side the caller is signing as, so the
|
||||
// server stores the signature under the right author
|
||||
// (Pro/Client) even when one user is both parties.
|
||||
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
|
||||
};
|
||||
|
||||
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
|
||||
|
|
@ -264,10 +325,65 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
[RelayCommand]
|
||||
public void ClearSignature()
|
||||
{
|
||||
_pad?.Clear();
|
||||
WritablePad?.Clear();
|
||||
RefreshFromPad();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate as a LaTeX source document. The server
|
||||
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
|
||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
|
||||
/// user picks a destination via the platform save picker.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadTexAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
|
||||
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate as a compiled PDF
|
||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
|
||||
/// Requires <c>texi2pdf</c> on the server host; a failure is
|
||||
/// surfaced as a status message.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadPdfAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
|
||||
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
||||
{
|
||||
if (_estimate.CommandId is not long)
|
||||
{
|
||||
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await fetch().ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved
|
||||
? $"Fichier .{ext} enregistré."
|
||||
: "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
|
|
@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
|||
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
||||
.ConfigureAwait(true);
|
||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
activity,
|
||||
performer,
|
||||
form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
details,
|
||||
isReadOnly: false);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
|
||||
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
|
||||
<Button Grid.Column="0"
|
||||
Content="Retour"
|
||||
Command="{Binding BackCommand}" />
|
||||
|
|
@ -22,6 +22,12 @@
|
|||
Content="Ouvrir en edition"
|
||||
Command="{Binding OpenEditorCommand}"
|
||||
IsVisible="{Binding CanEdit}" />
|
||||
<Button Grid.Column="4"
|
||||
Content="Télécharger PDF"
|
||||
Command="{Binding DownloadPdfCommand}" />
|
||||
<Button Grid.Column="6"
|
||||
Content="Télécharger TeX"
|
||||
Command="{Binding DownloadTexCommand}" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -14,4 +15,19 @@ public partial class EstimateListPage : ContentPage
|
|||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
// Re-fetch the list when returning to it (e.g. after validating an
|
||||
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
|
||||
// fires on the page popped back to with NavigationType.Pop; the list
|
||||
// VM is retained on the nav stack while away, so this refresh picks
|
||||
// up the server-side filter change that removed the signed estimate.
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
if (e.NavigationType == NavigationType.Pop
|
||||
&& DataContext is EstimateListPageViewModel vm)
|
||||
{
|
||||
_ = vm.RefreshAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,22 +82,38 @@
|
|||
CornerRadius="8"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Signature" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Signez ci-dessous pour valider ce devis."
|
||||
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ProSignatureHint}"
|
||||
FontSize="11"
|
||||
Opacity="0.7" />
|
||||
<controls:SignaturePadView x:Name="Pad"
|
||||
<controls:SignaturePadView x:Name="ProPad"
|
||||
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>
|
||||
CaptureHeight="160" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="8"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ClientSignatureHint}"
|
||||
FontSize="11"
|
||||
Opacity="0.7" />
|
||||
<controls:SignaturePadView x:Name="ClientPad"
|
||||
CaptureWidth="560"
|
||||
CaptureHeight="160" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock FontSize="11"
|
||||
Foreground="DarkSlateGray">
|
||||
<Run Text="Votre signature : traits " />
|
||||
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
|
||||
<Run Text=" · points " />
|
||||
<Run Text="{Binding PointCount, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
|
@ -109,6 +125,10 @@
|
|||
Command="{Binding ValidateCommand}" />
|
||||
<Button Content="Refuser"
|
||||
Command="{Binding RejectCommand}" />
|
||||
<Button Content="Télécharger PDF"
|
||||
Command="{Binding DownloadPdfCommand}" />
|
||||
<Button Content="Télécharger TeX"
|
||||
Command="{Binding DownloadTexCommand}" />
|
||||
<Button Content="Retour"
|
||||
Command="{Binding BackCommand}" />
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ public partial class EstimateValidationPage : ContentPage
|
|||
|
||||
_vm = vm;
|
||||
|
||||
if (_vm is null || Pad is null) return;
|
||||
if (_vm is null || ProPad is null || ClientPad is null) return;
|
||||
|
||||
_vm.Attach(Pad.SignaturePad);
|
||||
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,11 +26,11 @@
|
|||
Command="{Binding OpenProviderRequests}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis à valider"
|
||||
<Button Content="Devis en attente de signature client"
|
||||
Command="{Binding OpenClientEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis en attente"
|
||||
<Button Content="Devis en attente de signature fournisseur"
|
||||
Command="{Binding OpenProviderEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue