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();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
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);
|
Assert.Equal(2, vm.Estimates.Count);
|
||||||
// Most recent first
|
// Most recent first
|
||||||
Assert.Equal(2, vm.Estimates[0].Id);
|
Assert.Equal(2, vm.Estimates[0].Id);
|
||||||
|
|
@ -35,7 +35,7 @@ public class EstimateListPageViewModelTests
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
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);
|
Assert.Equal(2, vm.Estimates.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ using System.Net.Http;
|
||||||
using PostIt.Controls;
|
using PostIt.Controls;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the
|
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
|
||||||
/// signature pad + perspective into the right
|
/// signature pads (provider + client) into the right
|
||||||
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
||||||
/// is not tested here; the VM is driven through the same
|
/// is not tested here; the VM is driven through the same
|
||||||
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
||||||
|
|
@ -46,9 +47,10 @@ public class EstimateValidationPageViewModelTests
|
||||||
var vm = new EstimateValidationPageViewModel(
|
var vm = new EstimateValidationPageViewModel(
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||||
|
|
||||||
var pad = new SignaturePadControl();
|
var proPad = new SignaturePadControl();
|
||||||
vm.Attach(pad);
|
var clientPad = new SignaturePadControl();
|
||||||
DrawOneStroke(pad);
|
vm.Attach(proPad, clientPad);
|
||||||
|
DrawOneStroke(proPad); // provider signs on their writable pad
|
||||||
|
|
||||||
await vm.ValidateAsync();
|
await vm.ValidateAsync();
|
||||||
|
|
||||||
|
|
@ -60,6 +62,28 @@ public class EstimateValidationPageViewModelTests
|
||||||
Assert.NotNull(body.Strokes);
|
Assert.NotNull(body.Strokes);
|
||||||
Assert.NotEmpty(body.Strokes);
|
Assert.NotEmpty(body.Strokes);
|
||||||
Assert.Equal(10_000, body.CoordinateMax);
|
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]
|
[Fact]
|
||||||
|
|
@ -70,7 +94,7 @@ public class EstimateValidationPageViewModelTests
|
||||||
var vm = new EstimateValidationPageViewModel(
|
var vm = new EstimateValidationPageViewModel(
|
||||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||||
|
|
||||||
vm.Attach(new SignaturePadControl());
|
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
|
||||||
|
|
||||||
await vm.RejectAsync();
|
await vm.RejectAsync();
|
||||||
|
|
||||||
|
|
@ -88,7 +112,7 @@ public class EstimateValidationPageViewModelTests
|
||||||
var vm = new EstimateValidationPageViewModel(
|
var vm = new EstimateValidationPageViewModel(
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
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
|
await vm.ValidateAsync(); // no throw: returns before GoBack
|
||||||
|
|
||||||
|
|
@ -104,19 +128,19 @@ public class EstimateValidationPageViewModelTests
|
||||||
var vm = new EstimateValidationPageViewModel(
|
var vm = new EstimateValidationPageViewModel(
|
||||||
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
||||||
|
|
||||||
var pad = new SignaturePadControl();
|
var proPad = new SignaturePadControl();
|
||||||
vm.Attach(pad);
|
vm.Attach(proPad, new SignaturePadControl());
|
||||||
|
|
||||||
// No CommandId ⇒ cannot validate even after signing.
|
// No CommandId ⇒ cannot validate even after signing.
|
||||||
DrawOneStroke(pad);
|
DrawOneStroke(proPad);
|
||||||
Assert.False(vm.ValidateCommand.CanExecute(null));
|
Assert.False(vm.ValidateCommand.CanExecute(null));
|
||||||
|
|
||||||
var vm2 = new EstimateValidationPageViewModel(
|
var vm2 = new EstimateValidationPageViewModel(
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||||
var pad2 = new SignaturePadControl();
|
var proPad2 = new SignaturePadControl();
|
||||||
vm2.Attach(pad2);
|
vm2.Attach(proPad2, new SignaturePadControl());
|
||||||
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
||||||
DrawOneStroke(pad2);
|
DrawOneStroke(proPad2);
|
||||||
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,48 +158,67 @@ public class EstimateValidationPageViewModelTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 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();
|
var proVm = new EstimateValidationPageViewModel(
|
||||||
vm.Attach(pad);
|
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();
|
var cliVm = new EstimateValidationPageViewModel(
|
||||||
Assert.False(snap.IsEmpty);
|
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||||
Assert.Equal(strokes, snap.Strokes);
|
var proPad2 = new SignaturePadControl();
|
||||||
Assert.True(vm.HasSignature);
|
var cliPad2 = new SignaturePadControl();
|
||||||
|
cliVm.Attach(proPad2, cliPad2);
|
||||||
|
Assert.True(proPad2.IsReadOnly);
|
||||||
|
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 proStrokes = new[] { 1, 1000, 1000 };
|
||||||
var cliStrokes = new[] { 1, 9000, 9000 };
|
var cliStrokes = new[] { 1, 9000, 9000 };
|
||||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||||
var estimate = SampleEstimate();
|
var estimate = SampleEstimate();
|
||||||
estimate.Signatures = new List<EstimateSignatureDto>
|
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()
|
||||||
{
|
{
|
||||||
new() { Type = 0, Strokes = proStrokes },
|
var strokes = new[] { 1, 5000, 5000 };
|
||||||
new() { Type = 1, Strokes = cliStrokes },
|
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 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 cliPad = new SignaturePadControl();
|
||||||
var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
vm.Attach(proPad, cliPad);
|
||||||
cliVm.Attach(cliPad);
|
|
||||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
Assert.Equal(strokes, proPad.Snapshot().Strokes);
|
||||||
|
Assert.True(cliPad.Snapshot().IsEmpty);
|
||||||
|
Assert.True(vm.HasProSignature);
|
||||||
|
Assert.False(vm.HasClientSignature);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecordingApi : IYavscApiClient
|
private sealed class RecordingApi : IYavscApiClient
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,34 @@ public class FrontOfficeApiClientTests
|
||||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
|
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
|
private sealed class RecordingApi : IYavscApiClient
|
||||||
{
|
{
|
||||||
public HttpClient Http { get; } = new();
|
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)
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
=> CallAsync(method, path, (object?)null, ct);
|
=> 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;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
|
||||||
set => SetValue(CaptureAreaProperty, value);
|
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>
|
/// <summary>
|
||||||
/// Captured strokes in wire form. Exposed as a read-only view
|
/// Captured strokes in wire form. Exposed as a read-only view
|
||||||
/// over the internal buffer. The buffer only mutates on the UI
|
/// 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)
|
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (IsReadOnly) return;
|
||||||
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
||||||
e.Pointer.Capture(CaptureArea);
|
e.Pointer.Capture(CaptureArea);
|
||||||
_capturing = true;
|
_capturing = true;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ public partial class SignaturePadView : UserControl
|
||||||
private const double StrokeThickness = 2.0;
|
private const double StrokeThickness = 2.0;
|
||||||
private const double CoordinateMax = 10_000.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 =
|
public static readonly StyledProperty<double> CaptureWidthProperty =
|
||||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
||||||
|
|
||||||
|
|
@ -71,6 +76,19 @@ public partial class SignaturePadView : UserControl
|
||||||
ApplyFrameSize();
|
ApplyFrameSize();
|
||||||
|
|
||||||
InnerPad.RedrawRequested += OnPadRedrawRequested;
|
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)
|
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>
|
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
||||||
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
|
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()
|
private void Repaint()
|
||||||
{
|
{
|
||||||
if (InkLayer is null) return;
|
if (InkLayer is null) return;
|
||||||
|
|
||||||
|
var size = EffectivePadSize();
|
||||||
|
if (size == default) return;
|
||||||
|
_lastPaintedSize = size;
|
||||||
|
|
||||||
InkLayer.Children.Clear();
|
InkLayer.Children.Clear();
|
||||||
var w = PadFrame.Bounds.Width;
|
var w = size.Width;
|
||||||
var h = PadFrame.Bounds.Height;
|
var h = size.Height;
|
||||||
if (w <= 0 || h <= 0) return;
|
|
||||||
|
|
||||||
var pending = InnerPad.PendingStroke;
|
var pending = InnerPad.PendingStroke;
|
||||||
var strokes = InnerPad.Strokes;
|
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.";
|
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()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
_store.Clear();
|
_store.Clear();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using Yavsc;
|
using Yavsc;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||||
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
Activity,
|
Activity,
|
||||||
Performer,
|
Performer,
|
||||||
Form,
|
Form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
|
frontClient,
|
||||||
details,
|
details,
|
||||||
IsReadOnly);
|
IsReadOnly);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ namespace PostIt.ViewModels;
|
||||||
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly BillingApiClient _billingClient;
|
private readonly BillingApiClient _billingClient;
|
||||||
|
private readonly FrontOfficeApiClient _frontClient;
|
||||||
private readonly BillingQueryDetailsDto _details;
|
private readonly BillingQueryDetailsDto _details;
|
||||||
|
|
||||||
public ActivityInfo Activity { get; }
|
public ActivityInfo Activity { get; }
|
||||||
|
|
@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
ActivityUserDisplayItem performer,
|
ActivityUserDisplayItem performer,
|
||||||
CommandFormSummary form,
|
CommandFormSummary form,
|
||||||
BillingApiClient billingClient,
|
BillingApiClient billingClient,
|
||||||
|
FrontOfficeApiClient frontClient,
|
||||||
BillingQueryDetailsDto details,
|
BillingQueryDetailsDto details,
|
||||||
bool isReadOnly)
|
bool isReadOnly)
|
||||||
{
|
{
|
||||||
|
|
@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||||
|
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||||
IsReadOnly = isReadOnly;
|
IsReadOnly = isReadOnly;
|
||||||
|
|
||||||
|
|
@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
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)
|
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ namespace PostIt.ViewModels;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum EstimateListPerspective
|
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,
|
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,
|
Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,8 +40,8 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
public EstimateListPerspective Perspective => _perspective;
|
public EstimateListPerspective Perspective => _perspective;
|
||||||
|
|
||||||
public string Title => _perspective == EstimateListPerspective.Client
|
public string Title => _perspective == EstimateListPerspective.Client
|
||||||
? "Mes devis à valider"
|
? "Devis en attente de signature client"
|
||||||
: "Mes devis en attente";
|
: "Devis en attente de signature fournisseur";
|
||||||
|
|
||||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||||
? "Point de vue client"
|
? "Point de vue client"
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,34 @@ using PostIt.Controls;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using PostIt.Models;
|
using PostIt.Models;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validation (accept / reject) of a single estimate, with an optional
|
/// Validation (accept / reject) of a single estimate, with optional
|
||||||
/// signature. Backs the estimate validation page opened from
|
/// signatures. Backs the estimate validation page opened from
|
||||||
/// <see cref="EstimateListPageViewModel"/> for either perspective.
|
/// <see cref="EstimateListPageViewModel"/> for either perspective.
|
||||||
///
|
///
|
||||||
/// The server (<c>POST api/v1/front/query/accept</c>) resolves the
|
/// <para>Two signatures are shown, each in its own pad: the provider's
|
||||||
/// caller's role from the authenticated user compared to the query's
|
/// (Pro) and the client's (Client). Both are rendered from the estimate
|
||||||
/// <c>PerformerId</c>/<c>ClientId</c>; the perspective passed in here
|
/// payload when present, so reopening a partly-signed estimate shows
|
||||||
/// is only a UI hint (the <see cref="RoleLabel"/>). The billing code
|
/// what was already captured. Only the pad matching the current
|
||||||
/// sent to the server is the estimate's <see cref="EstimateDto.CommandType"/>
|
/// <see cref="EstimateListPerspective"/> is writable — the server
|
||||||
/// and the query id is its <see cref="EstimateDto.CommandId"/>.
|
/// (<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>
|
/// </summary>
|
||||||
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly EstimateDto _estimate;
|
private readonly EstimateDto _estimate;
|
||||||
private readonly EstimateListPerspective _perspective;
|
private readonly EstimateListPerspective _perspective;
|
||||||
private readonly FrontOfficeApiClient _frontClient;
|
private readonly FrontOfficeApiClient _frontClient;
|
||||||
private SignaturePadControl? _pad;
|
private SignaturePadControl? _proPad;
|
||||||
|
private SignaturePadControl? _clientPad;
|
||||||
|
|
||||||
public string Title => _estimate.Title;
|
public string Title => _estimate.Title;
|
||||||
public string Description => _estimate.Description;
|
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 prestataire"
|
||||||
: "Vous validez en tant que client";
|
: "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>
|
/// <summary>
|
||||||
/// Human-readable summary of who has already validated this
|
/// Human-readable summary of who has already validated this
|
||||||
/// estimate, shown above the signature pad.
|
/// estimate, shown above the signature pads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ValidationStateText
|
public string ValidationStateText
|
||||||
{
|
{
|
||||||
|
|
@ -78,15 +98,29 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||||
|
|
||||||
|
/// <summary>Stroke/point counts for the writable pad.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial int StrokeCount { get; set; }
|
public partial int StrokeCount { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial int PointCount { get; set; }
|
public partial int PointCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the writable pad holds a signature.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool HasSignature { get; set; }
|
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
|
public override bool CanNavigateNext
|
||||||
{
|
{
|
||||||
get => false;
|
get => false;
|
||||||
|
|
@ -110,58 +144,80 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind the page's signature pad control (the inner control of the
|
/// Bind the page's two signature pads (the inner controls of the
|
||||||
/// page's <see cref="SignaturePadView"/>). Called from the view's
|
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
|
||||||
/// code-behind once the view is in the visual tree. Also calls
|
/// the client's pad. Called from the view's code-behind once the
|
||||||
/// <see cref="LoadExistingSignature"/> so a previously drawn
|
/// view is in the visual tree. Marks the non-author pad read-only,
|
||||||
/// signature — carried on the estimate payload — is rendered back
|
/// then renders any signatures already carried on the estimate
|
||||||
/// into the pad on reopen.
|
/// payload.
|
||||||
/// </summary>
|
/// </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();
|
Detach();
|
||||||
_pad = pad;
|
_proPad = proPad;
|
||||||
_pad.RedrawRequested += OnPadRedraw;
|
_clientPad = clientPad;
|
||||||
|
_proPad.IsReadOnly = !CanWritePro;
|
||||||
|
_clientPad.IsReadOnly = !CanWriteClient;
|
||||||
|
_proPad.RedrawRequested += OnPadRedraw;
|
||||||
|
_clientPad.RedrawRequested += OnPadRedraw;
|
||||||
|
LoadExistingSignatures();
|
||||||
RefreshFromPad();
|
RefreshFromPad();
|
||||||
LoadExistingSignature();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Detach()
|
public void Detach()
|
||||||
{
|
{
|
||||||
if (_pad is null) return;
|
if (_proPad is not null)
|
||||||
_pad.RedrawRequested -= OnPadRedraw;
|
_proPad.RedrawRequested -= OnPadRedraw;
|
||||||
_pad = null;
|
if (_clientPad is not null)
|
||||||
|
_clientPad.RedrawRequested -= OnPadRedraw;
|
||||||
|
_proPad = null;
|
||||||
|
_clientPad = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
|
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>
|
/// <summary>
|
||||||
/// Render the signature already stored for this side of the
|
/// Render the signatures already stored for this estimate back into
|
||||||
/// estimate back into the pad. The strokes are carried on the
|
/// their pads. The strokes are carried on the estimate payload as
|
||||||
/// estimate payload (<see cref="EstimateDto.Signatures"/>); the
|
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
|
||||||
/// side is chosen by the current <see cref="_perspective"/> — the
|
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
|
||||||
/// provider reopens to see their "Pro" signature, the client their
|
/// its own side directly, in the same wire format PostIt captured.
|
||||||
/// "Client" signature. No network round-trip: the payload is used
|
/// No network round-trip.
|
||||||
/// as-is, in the same wire format PostIt captured.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void LoadExistingSignature()
|
public void LoadExistingSignatures()
|
||||||
{
|
{
|
||||||
if (_pad is null) return;
|
LoadSide(_proPad, _estimate.SignaturePro);
|
||||||
// SignatureType.Pro = 0, SignatureType.Client = 1 (server enum).
|
LoadSide(_clientPad, _estimate.SignatureClient);
|
||||||
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();
|
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()
|
private void RefreshFromPad()
|
||||||
{
|
{
|
||||||
if (_pad is null) return;
|
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
|
||||||
var snap = _pad.Snapshot();
|
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;
|
StrokeCount = snap.StrokeCount;
|
||||||
PointCount = snap.PointCount;
|
PointCount = snap.PointCount;
|
||||||
HasSignature = !snap.IsEmpty;
|
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é.");
|
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
|
||||||
return;
|
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.");
|
this.SetWarningStatus("Veuillez signer avant de valider.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var snap = _pad.Snapshot();
|
var snap = pad.Snapshot();
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -203,6 +260,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
||||||
Strokes = snap.Strokes,
|
Strokes = snap.Strokes,
|
||||||
CoordinateMax = SignaturePadData.CoordinateMax,
|
CoordinateMax = SignaturePadData.CoordinateMax,
|
||||||
CapturedAtUtc = DateTime.UtcNow,
|
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)
|
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
|
||||||
|
|
@ -264,10 +325,65 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ClearSignature()
|
public void ClearSignature()
|
||||||
{
|
{
|
||||||
_pad?.Clear();
|
WritablePad?.Clear();
|
||||||
RefreshFromPad();
|
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]
|
[RelayCommand]
|
||||||
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
|
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using Yavsc;
|
using Yavsc;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
|
|
@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||||
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
|
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
activity,
|
activity,
|
||||||
performer,
|
performer,
|
||||||
form,
|
form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
|
frontClient,
|
||||||
details,
|
details,
|
||||||
isReadOnly: false);
|
isReadOnly: false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
||||||
</StackPanel>
|
</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"
|
<Button Grid.Column="0"
|
||||||
Content="Retour"
|
Content="Retour"
|
||||||
Command="{Binding BackCommand}" />
|
Command="{Binding BackCommand}" />
|
||||||
|
|
@ -22,6 +22,12 @@
|
||||||
Content="Ouvrir en edition"
|
Content="Ouvrir en edition"
|
||||||
Command="{Binding OpenEditorCommand}"
|
Command="{Binding OpenEditorCommand}"
|
||||||
IsVisible="{Binding CanEdit}" />
|
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>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -14,4 +15,19 @@ public partial class EstimateListPage : ContentPage
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
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"
|
CornerRadius="8"
|
||||||
Padding="10">
|
Padding="10">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Text="Signature" FontWeight="SemiBold" />
|
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
|
||||||
<TextBlock Text="Signez ci-dessous pour valider ce devis."
|
<TextBlock Text="{Binding ProSignatureHint}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.7" />
|
Opacity="0.7" />
|
||||||
<controls:SignaturePadView x:Name="Pad"
|
<controls:SignaturePadView x:Name="ProPad"
|
||||||
CaptureWidth="560"
|
CaptureWidth="560"
|
||||||
CaptureHeight="200" />
|
CaptureHeight="160" />
|
||||||
<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>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|
@ -109,6 +125,10 @@
|
||||||
Command="{Binding ValidateCommand}" />
|
Command="{Binding ValidateCommand}" />
|
||||||
<Button Content="Refuser"
|
<Button Content="Refuser"
|
||||||
Command="{Binding RejectCommand}" />
|
Command="{Binding RejectCommand}" />
|
||||||
|
<Button Content="Télécharger PDF"
|
||||||
|
Command="{Binding DownloadPdfCommand}" />
|
||||||
|
<Button Content="Télécharger TeX"
|
||||||
|
Command="{Binding DownloadTexCommand}" />
|
||||||
<Button Content="Retour"
|
<Button Content="Retour"
|
||||||
Command="{Binding BackCommand}" />
|
Command="{Binding BackCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ public partial class EstimateValidationPage : ContentPage
|
||||||
|
|
||||||
_vm = vm;
|
_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}"
|
Command="{Binding OpenProviderRequests}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mes devis à valider"
|
<Button Content="Devis en attente de signature client"
|
||||||
Command="{Binding OpenClientEstimates}"
|
Command="{Binding OpenClientEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mes devis en attente"
|
<Button Content="Devis en attente de signature fournisseur"
|
||||||
Command="{Binding OpenProviderEstimates}"
|
Command="{Binding OpenProviderEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
|
|
||||||
|
|
@ -41,32 +41,34 @@ public sealed class EstimateDto
|
||||||
public DateTime ClientValidationDate { get; set; }
|
public DateTime ClientValidationDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Signatures already captured against this estimate (Pro and/or
|
/// The provider's signature on this estimate, or null when the
|
||||||
/// Client), as returned with the estimate payload. Each carries the
|
/// provider has not signed. Carries the PostIt wire-format
|
||||||
/// PostIt wire-format <see cref="EstimateSignatureDto.Strokes"/>
|
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
|
||||||
/// so the pad can repaint a previously drawn signature on reopen
|
/// repaint a previously drawn signature on reopen without a second
|
||||||
/// without a second round-trip. Null/empty when nobody has signed.
|
/// round-trip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<EstimateSignatureDto>? Signatures { get; set; }
|
public EstimateSignatureDto? SignaturePro { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client's signature on this estimate, or null when the
|
||||||
|
/// client has not signed.
|
||||||
|
/// </summary>
|
||||||
|
public EstimateSignatureDto? SignatureClient { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One stored signature on an estimate, mirroring the JSON shape of
|
/// One stored signature on an estimate, mirroring the JSON shape of
|
||||||
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
|
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
|
||||||
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
|
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
|
||||||
/// was captured and are loaded back into the pad unchanged.
|
/// was captured and are loaded back into the pad unchanged. The side
|
||||||
|
/// (Pro/Client) is implicit in which property of
|
||||||
|
/// <see cref="EstimateDto"/> carries this DTO, so no <c>Type</c>
|
||||||
|
/// field is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EstimateSignatureDto
|
public sealed class EstimateSignatureDto
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public long EstimateId { get; set; }
|
public long EstimateId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Who signed, as the integer value of the server
|
|
||||||
/// <c>SignatureType</c> enum: <c>0</c> = Pro (provider),
|
|
||||||
/// <c>1</c> = Client. Matched by value on the client so PostIt
|
|
||||||
/// need not reference the server enum.
|
|
||||||
/// </summary>
|
|
||||||
public int Type { get; set; }
|
|
||||||
public int CoordinateMax { get; set; }
|
public int CoordinateMax { get; set; }
|
||||||
public DateTime CapturedAtUtc { get; set; }
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
public int[]? Strokes { get; set; }
|
public int[]? Strokes { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
namespace Yavsc.Api.Client;
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
|
@ -17,6 +18,17 @@ public sealed class QueryAcceptanceRequestDto
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
public DateTime? CapturedAtUtc { get; set; }
|
public DateTime? CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which signature is being submitted:
|
||||||
|
/// <see cref="SignatureType.Pro"/> (the provider) or
|
||||||
|
/// <see cref="SignatureType.Client"/>. The PostIt client sets this
|
||||||
|
/// from the perspective the page was opened in (provider signs Pro,
|
||||||
|
/// client signs Client), so the server stores the signature under
|
||||||
|
/// the right author even when one user is both parties. Null lets
|
||||||
|
/// the server infer the role from the caller's identity.
|
||||||
|
/// </summary>
|
||||||
|
public SignatureType? SignatureType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,33 @@ public sealed class FrontOfficeApiClient
|
||||||
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download the estimate linked to query <paramref name="queryId"/>
|
||||||
|
/// as a LaTeX (<c>.tex</c>) source document
|
||||||
|
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
|
||||||
|
/// the raw TeX bytes (UTF-8 text).
|
||||||
|
/// </summary>
|
||||||
|
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
|
||||||
|
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download the estimate linked to query <paramref name="queryId"/>
|
||||||
|
/// as a compiled PDF document
|
||||||
|
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
|
||||||
|
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
|
||||||
|
/// a non-2xx response throws <see cref="HttpRequestException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
|
||||||
|
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
||||||
|
}
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,23 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
string path,
|
string path,
|
||||||
Func<HttpContent> contentFactory,
|
Func<HttpContent> contentFactory,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download a binary resource (PDF, TeX, image, …). Unlike
|
||||||
|
/// <see cref="CallAsync{T}(HttpMethod,string,object?,CancellationToken)"/>,
|
||||||
|
/// which JSON-decodes the response, this returns the raw response
|
||||||
|
/// bytes. The same Bearer-token attach + 401-refresh-retry-once
|
||||||
|
/// contract applies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A default interface implementation is provided so that test
|
||||||
|
/// fakes that do not exercise binary download still compile without
|
||||||
|
/// declaring the member; the real <see cref="YavscApiClient"/>
|
||||||
|
/// overrides it with the authenticated transport.
|
||||||
|
/// </remarks>
|
||||||
|
Task<byte[]> DownloadAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(Array.Empty<byte>());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,11 +284,14 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
|
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
|
||||||
{
|
{
|
||||||
_fixture.ResetAndSeedActivityGraph();
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
|
// Not yet provider-signed: visible in the provider's "awaiting
|
||||||
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
|
// provider signature" list.
|
||||||
|
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
|
||||||
|
// Provider already signed: must leave the list immediately.
|
||||||
|
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
|
||||||
|
|
||||||
using var http = NewClient("alice");
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
|
@ -300,7 +303,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
var items = doc.RootElement.EnumerateArray().ToArray();
|
||||||
var item = Assert.Single(items);
|
var item = Assert.Single(items);
|
||||||
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
|
Assert.Equal(pendingId, item.GetProperty("id").GetInt64());
|
||||||
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||||
Assert.Single(item.GetProperty("bill").EnumerateArray());
|
Assert.Single(item.GetProperty("bill").EnumerateArray());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,19 +45,26 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
|
|
||||||
private static HttpContent NoSignature() => JsonBody(new { });
|
private static HttpContent NoSignature() => JsonBody(new { });
|
||||||
|
|
||||||
private static HttpContent WithSignature() =>
|
private static HttpContent WithSignature(SignatureType? type = null) =>
|
||||||
JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
|
type is null
|
||||||
|
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
|
||||||
|
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
|
||||||
|
|
||||||
private static JsonContent JsonBody(object value) =>
|
private static JsonContent JsonBody(object value) =>
|
||||||
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds an Rdv query with distinct parties (provider alice,
|
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
|
||||||
/// client bob) and, when <paramref name="withEstimate"/> is true,
|
/// true, an estimate linked to it via CommandId. The provider is
|
||||||
/// an estimate linked to it via CommandId. Returns the query id
|
/// <paramref name="performer"/> (default alice) and the client is
|
||||||
/// and (when seeded) the estimate id.
|
/// <paramref name="client"/> (default bob) — pass the same value
|
||||||
|
/// for both to model a user who is both parties. Returns the query
|
||||||
|
/// id and (when seeded) the estimate id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private (long queryId, long estimateId) SeedQuery(bool withEstimate)
|
private (long queryId, long estimateId) SeedQuery(
|
||||||
|
bool withEstimate,
|
||||||
|
string performer = "alice",
|
||||||
|
string client = "bob")
|
||||||
{
|
{
|
||||||
WorkflowHelpers.ConfigureBillingService();
|
WorkflowHelpers.ConfigureBillingService();
|
||||||
_fixture.ResetAndSeedActivityGraph();
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
@ -70,11 +77,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var query = new RdvQuery
|
var query = new RdvQuery
|
||||||
{
|
{
|
||||||
ActivityCode = "dev",
|
ActivityCode = "dev",
|
||||||
ClientId = "bob",
|
ClientId = client,
|
||||||
PerformerId = "alice",
|
PerformerId = performer,
|
||||||
Consent = true,
|
Consent = true,
|
||||||
UserCreated = "alice",
|
UserCreated = performer,
|
||||||
UserModified = "alice",
|
UserModified = performer,
|
||||||
DateCreated = DateTime.UtcNow,
|
DateCreated = DateTime.UtcNow,
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
EventDate = DateTime.UtcNow.AddDays(1),
|
EventDate = DateTime.UtcNow.AddDays(1),
|
||||||
|
|
@ -91,8 +98,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var estimate = new Estimate
|
var estimate = new Estimate
|
||||||
{
|
{
|
||||||
CommandId = query.Id,
|
CommandId = query.Id,
|
||||||
ClientId = "bob",
|
ClientId = client,
|
||||||
OwnerId = "alice",
|
OwnerId = performer,
|
||||||
CommandType = BillingCodes.Rdv,
|
CommandType = BillingCodes.Rdv,
|
||||||
Title = "Devis prestation",
|
Title = "Devis prestation",
|
||||||
Description = "Devis de test",
|
Description = "Devis de test",
|
||||||
|
|
@ -271,11 +278,65 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||||
var sigs = body.GetProperty("signatures");
|
|
||||||
Assert.Equal(JsonValueKind.Array, sigs.ValueKind);
|
// Signatures ride on the estimate payload as two dedicated
|
||||||
var first = sigs.EnumerateArray().First();
|
// slots, not a list: the provider signed here, so signaturePro
|
||||||
// SignatureType.Pro = 0.
|
// is populated and signatureClient is absent/null.
|
||||||
Assert.Equal(0, first.GetProperty("type").GetInt32());
|
var proSig = body.GetProperty("signaturePro");
|
||||||
Assert.Equal(JsonValueKind.Array, first.GetProperty("strokes").ValueKind);
|
Assert.Equal(JsonValueKind.Object, proSig.ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Array, proSig.GetProperty("strokes").ValueKind);
|
||||||
|
Assert.True(body.TryGetProperty("signatureClient", out var cli)
|
||||||
|
&& cli.ValueKind == JsonValueKind.Null,
|
||||||
|
"signatureClient should be null when only the provider has signed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// When one user is both the provider and the client of a query, the
|
||||||
|
// server cannot infer which side they are signing as from identity
|
||||||
|
// alone (it would always pick the provider). The caller declares the
|
||||||
|
// side (signatureType=1 here = Client); the server honors it because
|
||||||
|
// the caller is that party, and stores a Client signature.
|
||||||
|
[Fact]
|
||||||
|
public async Task Accept_declared_client_side_stores_client_signature_when_user_is_both_parties()
|
||||||
|
{
|
||||||
|
var (queryId, estimateId) = SeedQuery(withEstimate: true, performer: "alice", client: "alice");
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(SignatureType.Client), // signing as the client
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var assertScope = _fixture.Services.CreateScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||||
|
|
||||||
|
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
||||||
|
Assert.True(estimate.ClientValidationDate != default);
|
||||||
|
Assert.True(estimate.ProviderValidationDate == default);
|
||||||
|
|
||||||
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
|
Assert.Equal(SignatureType.Client, signature.Type);
|
||||||
|
Assert.Equal("alice", signature.SignerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider may not sign as the client (and vice versa): declaring
|
||||||
|
// the other party's side is refused even though the caller is a
|
||||||
|
// party to the query. Write is authorized only for the signature's
|
||||||
|
// author.
|
||||||
|
[Fact]
|
||||||
|
public async Task Accept_declaring_other_party_side_is_forbidden()
|
||||||
|
{
|
||||||
|
var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice"); // alice is the provider
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(SignatureType.Client), // alice tries to sign as the client
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -335,9 +335,9 @@ namespace Yavsc.ApiControllers
|
||||||
return new ChallengeResult();
|
return new ChallengeResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
|
||||||
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
||||||
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
|
||||||
return File(fi.OpenRead(), "application/x-pdf", filename);
|
return File(fi.OpenRead(), "application/x-pdf", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,14 +216,16 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/estimate/asprovider
|
// GET: api/estimate/asprovider
|
||||||
// Ongoing estimates (not yet validated by the client) established
|
// Estimates awaiting the provider's signature, established by the
|
||||||
// by the current user as the provider.
|
// current user as the provider. Once the provider validates
|
||||||
|
// (ProviderValidationDate stamped), the estimate leaves this list
|
||||||
|
// and appears in the client's "awaiting client signature" list.
|
||||||
[HttpGet("asprovider")]
|
[HttpGet("asprovider")]
|
||||||
public IActionResult GetOngoingEstimatesAsProvider()
|
public IActionResult GetOngoingEstimatesAsProvider()
|
||||||
{
|
{
|
||||||
var uid = User.GetUserId();
|
var uid = User.GetUserId();
|
||||||
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
||||||
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
|
.Where(e => e.OwnerId == uid && e.ProviderValidationDate == default));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
|
@ -8,6 +11,7 @@ using Yavsc.Models.Billing;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
|
using Yavsc.ViewModels.Gen;
|
||||||
|
|
||||||
namespace Yavsc.ApiControllers
|
namespace Yavsc.ApiControllers
|
||||||
{
|
{
|
||||||
|
|
@ -18,15 +22,36 @@ namespace Yavsc.ApiControllers
|
||||||
ApplicationDbContext dbContext;
|
ApplicationDbContext dbContext;
|
||||||
private readonly IBillingService billing;
|
private readonly IBillingService billing;
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
|
private readonly SiteSettings siteSettings;
|
||||||
|
private readonly IViewEngine viewEngine;
|
||||||
|
|
||||||
|
// The view-rendering dependencies (siteSettings, viewEngine) are
|
||||||
|
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
||||||
|
// the Api host where Program.cs wires AddControllersWithViews +
|
||||||
|
// SiteSettings. The accept/reject endpoints (and the Api integration
|
||||||
|
// test host, which composes with AddControllers alone) never touch
|
||||||
|
// them, so they must not be required for controller activation.
|
||||||
|
//
|
||||||
|
// .NET 10 does not register a single IViewEngine service (the view
|
||||||
|
// engine is registered as IRazorViewEngine and surfaced through
|
||||||
|
// MvcViewOptions.ViewEngines). IRazorViewEngine (RazorViewEngine)
|
||||||
|
// implements IViewEngine, so we inject that and hand it to
|
||||||
|
// TeXHelpers.RenderViewToString as an IViewEngine. RenderViewToString
|
||||||
|
// builds its ActionContext from the controller's own ControllerContext,
|
||||||
|
// so no IActionContextAccessor is needed (and that type is deprecated
|
||||||
|
// in .NET 10).
|
||||||
public FrontOfficeApiController(
|
public FrontOfficeApiController(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
|
IOptions<SiteSettings> siteSettings = null,
|
||||||
|
IRazorViewEngine viewEngine = null,
|
||||||
IBillingService billing = null)
|
IBillingService billing = null)
|
||||||
{
|
{
|
||||||
dbContext = context;
|
dbContext = context;
|
||||||
this.billing = billing ?? new BillingService(context);
|
this.billing = billing ?? new BillingService(context);
|
||||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||||
|
this.siteSettings = siteSettings?.Value;
|
||||||
|
this.viewEngine = viewEngine as IViewEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("profiles/{actCode}")]
|
[HttpGet("profiles/{actCode}")]
|
||||||
|
|
@ -35,6 +60,120 @@ namespace Yavsc.ApiControllers
|
||||||
return await dbContext.ListPerformersAsync(billing, actCode);
|
return await dbContext.ListPerformersAsync(billing, actCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/front/query/{queryId}/estimate.tex
|
||||||
|
//
|
||||||
|
// Renders the estimate linked to the query (Estimate.CommandId ==
|
||||||
|
// queryId) as a LaTeX source document. The view is a generation
|
||||||
|
// template (Layout = "null"), not a web page: it is returned with
|
||||||
|
// a text/x-tex content type so the caller can save it as a .tex
|
||||||
|
// file. Either party (client or provider) or an admin may read it.
|
||||||
|
[HttpGet("query/{queryId}/estimate.tex")]
|
||||||
|
public async Task<IActionResult> EstimateTex(long queryId, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (viewEngine is null || siteSettings is null)
|
||||||
|
return Problem("TeX rendering is not configured on this host.",
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
|
||||||
|
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
||||||
|
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName)
|
||||||
|
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
PrepareEstimateViewBag(estimate);
|
||||||
|
Response.ContentType = "text/x-tex";
|
||||||
|
return View("Estimate_tex", estimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: api/front/query/{queryId}/estimate.pdf
|
||||||
|
//
|
||||||
|
// Renders the same LaTeX template to a string, compiles it to PDF
|
||||||
|
// via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams
|
||||||
|
// the generated PDF bytes back. Requires /usr/bin/texi2pdf on the
|
||||||
|
// server host; on hosts without it the endpoint returns a problem.
|
||||||
|
[HttpGet("query/{queryId}/estimate.pdf")]
|
||||||
|
public async Task<IActionResult> EstimatePdf(long queryId, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (viewEngine is null || siteSettings is null)
|
||||||
|
return Problem("PDF rendering is not configured on this host.",
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
|
||||||
|
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
||||||
|
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName)
|
||||||
|
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
PrepareEstimateViewBag(estimate);
|
||||||
|
|
||||||
|
var baseFileName = $"estimate-{estimate.Id}";
|
||||||
|
// Resolve the bills dir to an absolute path once: appsettings
|
||||||
|
// configures it as a relative path ("bills"), and both the
|
||||||
|
// generation (GenerateEstimatePdf) and the read-back below must
|
||||||
|
// agree on the same location regardless of the host's cwd.
|
||||||
|
var billsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
||||||
|
var model = new PdfGenerationViewModel
|
||||||
|
{
|
||||||
|
BaseFileName = baseFileName,
|
||||||
|
DestDir = billsDir,
|
||||||
|
Temp = siteSettings.TempDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
|
||||||
|
// string, using the estimate as the view model.
|
||||||
|
model.TeXSource = this.RenderViewToString(viewEngine, "Estimate_tex", estimate);
|
||||||
|
// 2. Render the generation view (Estimate_pdf). Its @{} block
|
||||||
|
// calls SiteSettings.GenerateEstimatePdf(Model), which
|
||||||
|
// compiles the TeX to PDF on disk as a side effect of the
|
||||||
|
// render. The HTML the view emits is not what we return —
|
||||||
|
// we stream the generated PDF bytes — but driving the
|
||||||
|
// generation through the view keeps the template as the
|
||||||
|
// output producer, per the original design.
|
||||||
|
this.RenderViewToString(viewEngine, "Estimate_pdf", model);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
|
||||||
|
return Problem("TeX render failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.Generated)
|
||||||
|
return Problem("PDF generation failed: "
|
||||||
|
+ (model.GenerationErrorMessage?.Value ?? "unknown error"));
|
||||||
|
|
||||||
|
var pdfPath = System.IO.Path.Combine(billsDir, baseFileName + ".pdf");
|
||||||
|
var bytes = await System.IO.File.ReadAllBytesAsync(pdfPath, token);
|
||||||
|
return File(bytes, "application/pdf", baseFileName + ".pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the estimate linked to a query with the navigation the
|
||||||
|
// Estimate_tex template reads (client + performer profile + bill).
|
||||||
|
private Task<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token)
|
||||||
|
=> dbContext.Estimates
|
||||||
|
.Where(e => e.CommandId == queryId)
|
||||||
|
.OrderByDescending(e => e.Id)
|
||||||
|
.Include(e => e.Bill)
|
||||||
|
.Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress)
|
||||||
|
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress)
|
||||||
|
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer)
|
||||||
|
.FirstOrDefaultAsync(token);
|
||||||
|
|
||||||
|
// Sets the ViewBag keys the Estimate_tex template reads. A devis
|
||||||
|
// is never a bill (AsBill=false) and is never acquitted.
|
||||||
|
private void PrepareEstimateViewBag(Estimate estimate)
|
||||||
|
{
|
||||||
|
ViewBag.AsBill = false;
|
||||||
|
ViewBag.Acquitted = false;
|
||||||
|
ViewBag.BillsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
||||||
|
ViewBag.AvatarsDir = new System.IO.DirectoryInfo(siteSettings.Avatars).FullName;
|
||||||
|
}
|
||||||
|
|
||||||
// POST: api/front/query/accept
|
// POST: api/front/query/accept
|
||||||
//
|
//
|
||||||
// Either party of the query may accept: the provider
|
// Either party of the query may accept: the provider
|
||||||
|
|
@ -57,6 +196,15 @@ namespace Yavsc.ApiControllers
|
||||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
Signature? signature = null;
|
Signature? signature = null;
|
||||||
|
// actingRole is the role the caller is signing / accepting
|
||||||
|
// as. It defaults to the role inferred from the caller's
|
||||||
|
// identity, but when a signature is submitted the caller
|
||||||
|
// declares the side (Pro/Client) and the server honors it
|
||||||
|
// only if the caller is that party. This disambiguates a
|
||||||
|
// user who is both parties (where inference would always
|
||||||
|
// pick the provider) and makes "write only for its author"
|
||||||
|
// explicit.
|
||||||
|
ActorRole actingRole = role;
|
||||||
if (HasSignature(body))
|
if (HasSignature(body))
|
||||||
{
|
{
|
||||||
// Admins bypass authorization but are neither party of
|
// Admins bypass authorization but are neither party of
|
||||||
|
|
@ -71,20 +219,37 @@ namespace Yavsc.ApiControllers
|
||||||
if (estimate is null)
|
if (estimate is null)
|
||||||
return BadRequest(new { Error = "no estimate linked to this query" });
|
return BadRequest(new { Error = "no estimate linked to this query" });
|
||||||
|
|
||||||
var type = role == ActorRole.Provider
|
var uid = User.GetUserId();
|
||||||
|
bool isProvider = query.PerformerId == uid;
|
||||||
|
bool isClient = query.ClientId == uid;
|
||||||
|
|
||||||
|
// The caller declares which side they sign as. Null
|
||||||
|
// falls back to the role inferred from identity.
|
||||||
|
var declared = body!.SignatureType;
|
||||||
|
if (declared.HasValue)
|
||||||
|
{
|
||||||
|
if (declared.Value == SignatureType.Pro && !isProvider)
|
||||||
|
return Forbid();
|
||||||
|
if (declared.Value == SignatureType.Client && !isClient)
|
||||||
|
return Forbid();
|
||||||
|
actingRole = declared.Value == SignatureType.Pro
|
||||||
|
? ActorRole.Provider
|
||||||
|
: ActorRole.Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = actingRole == ActorRole.Provider
|
||||||
? SignatureType.Pro
|
? SignatureType.Pro
|
||||||
: SignatureType.Client;
|
: SignatureType.Client;
|
||||||
|
|
||||||
var payload = new SignaturePadPayload
|
var payload = new SignaturePadPayload
|
||||||
{
|
{
|
||||||
CoordinateMax = body!.CoordinateMax,
|
CoordinateMax = body.CoordinateMax,
|
||||||
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
||||||
Strokes = body.Strokes!,
|
Strokes = body.Strokes!,
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uid = User.GetUserId();
|
|
||||||
signature = await EstimateSignaturePersister.StageAsync(
|
signature = await EstimateSignaturePersister.StageAsync(
|
||||||
dbContext, estimate.Id, type, uid, payload, token);
|
dbContext, estimate.Id, type, uid, payload, token);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +259,7 @@ namespace Yavsc.ApiControllers
|
||||||
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
|
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role == ActorRole.Provider)
|
if (actingRole == ActorRole.Provider)
|
||||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||||
else
|
else
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||||
|
|
@ -104,7 +269,7 @@ namespace Yavsc.ApiControllers
|
||||||
// provider (ProAccepted) or the client (ClientAccepted).
|
// provider (ProAccepted) or the client (ClientAccepted).
|
||||||
// An admin accepting (neither party) falls back to the
|
// An admin accepting (neither party) falls back to the
|
||||||
// legacy generic Accepted.
|
// legacy generic Accepted.
|
||||||
query.Status = role switch
|
query.Status = actingRole switch
|
||||||
{
|
{
|
||||||
ActorRole.Provider => QueryStatus.ProAccepted,
|
ActorRole.Provider => QueryStatus.ProAccepted,
|
||||||
ActorRole.Client => QueryStatus.ClientAccepted,
|
ActorRole.Client => QueryStatus.ClientAccepted,
|
||||||
|
|
@ -222,5 +387,19 @@ namespace Yavsc.ViewModels.FrontOffice
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
public DateTime? CapturedAtUtc { get; set; }
|
public DateTime? CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which signature is being submitted:
|
||||||
|
/// <see cref="SignatureType.Pro"/> (the provider) or
|
||||||
|
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
|
||||||
|
/// is present this declares the side the caller is signing as;
|
||||||
|
/// the server honors it only if the caller is that party
|
||||||
|
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
|
||||||
|
/// for Client). This disambiguates the case where one user is
|
||||||
|
/// both parties, and makes "write only for its author"
|
||||||
|
/// explicit. When null, the server falls back to the role
|
||||||
|
/// inferred from the caller's identity.
|
||||||
|
/// </summary>
|
||||||
|
public SignatureType? SignatureType { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,10 +3,10 @@ using Microsoft.AspNetCore.Html;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
|
|
||||||
namespace Yavsc.Helpers
|
namespace Yavsc.Helpers
|
||||||
{
|
{
|
||||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
|
||||||
using ViewModels.Gen;
|
using ViewModels.Gen;
|
||||||
public class TeXString : HtmlString
|
public class TeXString : HtmlString
|
||||||
{
|
{
|
||||||
|
|
@ -118,51 +118,65 @@ namespace Yavsc.Helpers
|
||||||
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
||||||
{
|
{
|
||||||
string errorMsg = null;
|
string errorMsg = null;
|
||||||
var billdir = Model.DestDir;
|
// Resolve to an absolute path: SiteSettings.Bills may be a
|
||||||
var tempdir = settings.TempDir;
|
// relative path (e.g. "bills"), and lualatex resolves
|
||||||
|
// -output-directory against its working directory — passing a
|
||||||
|
// relative path to both would nest them (<cwd>/bills/bills) and
|
||||||
|
// the log/pdf write would fail.
|
||||||
|
var billdir = new DirectoryInfo(Model.DestDir).FullName;
|
||||||
string name = Model.BaseFileName;
|
string name = Model.BaseFileName;
|
||||||
string fullname = new FileInfo(
|
FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf"));
|
||||||
System.IO.Path.Combine(tempdir, name)).FullName;
|
System.IO.Directory.CreateDirectory(billdir);
|
||||||
string ofullname = new FileInfo(
|
|
||||||
System.IO.Path.Combine(billdir, name)).FullName;
|
|
||||||
|
|
||||||
FileInfo fi = new FileInfo(fullname + ".tex");
|
// Compile the LaTeX source with the system lualatex, feeding it
|
||||||
FileInfo fo = new FileInfo(ofullname + ".pdf");
|
// the TeX on standard input (no intermediate .tex file) and
|
||||||
using (StreamWriter sw = new StreamWriter(fi.FullName))
|
// directing the resulting <name>.pdf straight into the bills
|
||||||
{
|
// directory. lualatex is a file-based engine — it cannot emit
|
||||||
sw.Write(Model.TeXSource);
|
// the PDF on stdout — so the pdf lands at its final destination
|
||||||
}
|
// and we only clean the transient aux/log artifacts beside it.
|
||||||
if (!fi.Exists)
|
int exitCode;
|
||||||
{
|
try
|
||||||
errorMsg = "Source write failed";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
using (Process p = new Process())
|
using (Process p = new Process())
|
||||||
{
|
{
|
||||||
p.StartInfo.WorkingDirectory = tempdir;
|
|
||||||
p.StartInfo = new ProcessStartInfo
|
p.StartInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
WorkingDirectory = tempdir,
|
WorkingDirectory = billdir,
|
||||||
FileName = "/usr/bin/texi2pdf",
|
FileName = "lualatex",
|
||||||
Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}"
|
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
||||||
|
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\"",
|
||||||
|
RedirectStandardInput = true,
|
||||||
};
|
};
|
||||||
p.Start();
|
p.Start();
|
||||||
|
using (var stdin = p.StandardInput)
|
||||||
|
{
|
||||||
|
stdin.Write(Model.TeXSource);
|
||||||
|
}
|
||||||
p.WaitForExit();
|
p.WaitForExit();
|
||||||
if (p.ExitCode != 0)
|
exitCode = p.ExitCode;
|
||||||
{
|
|
||||||
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
|
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
fi.Delete();
|
errorMsg = "lualatex invocation failed: " + ex.Message;
|
||||||
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
|
exitCode = -1;
|
||||||
di.Delete(true);
|
}
|
||||||
|
|
||||||
|
if (exitCode != 0 && errorMsg == null)
|
||||||
|
errorMsg = $"Pdf generation failed with exit code: {exitCode}";
|
||||||
|
else if (!fo.Exists && errorMsg == null)
|
||||||
|
errorMsg = "Pdf generation produced no output";
|
||||||
|
|
||||||
|
// Remove the LaTeX build artifacts left beside the pdf so only
|
||||||
|
// <name>.pdf persists in the bills directory.
|
||||||
|
foreach (var ext in new[] { ".aux", ".log", ".out", ".fls",
|
||||||
|
".fdb_latexmk", ".synctex.gz", ".toc" })
|
||||||
|
{
|
||||||
|
var f = new FileInfo(System.IO.Path.Combine(billdir, name + ext));
|
||||||
|
if (f.Exists) { try { f.Delete(); } catch { } }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Model.Generated = fo.Exists;
|
Model.Generated = fo.Exists;
|
||||||
Model.GenerationErrorMessage = new HtmlString(errorMsg);
|
Model.GenerationErrorMessage = new HtmlString(errorMsg);
|
||||||
return fo.Exists;
|
return fo.Exists;
|
||||||
|
|
@ -170,27 +184,41 @@ namespace Yavsc.Helpers
|
||||||
|
|
||||||
public static string RenderViewToString(
|
public static string RenderViewToString(
|
||||||
this Controller controller, IViewEngine engine,
|
this Controller controller, IViewEngine engine,
|
||||||
IActionContextAccessor contextAccessor,
|
|
||||||
string viewName, object model, bool isMainPage = true)
|
string viewName, object model, bool isMainPage = true)
|
||||||
{
|
|
||||||
using (var sw = new StringWriter())
|
|
||||||
{
|
{
|
||||||
if (engine == null)
|
if (engine == null)
|
||||||
throw new InvalidOperationException("no engine");
|
throw new InvalidOperationException("no engine");
|
||||||
|
|
||||||
// try to find the specified view
|
// Build an ActionContext from the controller's own context —
|
||||||
controller.TryValidateModel(model);
|
// .NET 10 deprecated IActionContextAccessor, and the controller
|
||||||
ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage);
|
// already holds the live request context on its ControllerContext.
|
||||||
|
var controllerContext = controller.ControllerContext;
|
||||||
|
var actionContext = new ActionContext(
|
||||||
|
controllerContext.HttpContext,
|
||||||
|
controllerContext.RouteData,
|
||||||
|
controllerContext.ActionDescriptor);
|
||||||
|
|
||||||
// create the associated context
|
ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage);
|
||||||
ViewContext viewContext = new ViewContext();
|
if (!viewResult.Success)
|
||||||
viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor;
|
throw new InvalidOperationException(
|
||||||
viewContext.HttpContext = contextAccessor.ActionContext.HttpContext;
|
$"View '{viewName}' not found. Searched locations: "
|
||||||
viewContext.TempData = controller.TempData;
|
+ string.Join(", ", viewResult.SearchedLocations));
|
||||||
viewContext.View = viewResult.View;
|
|
||||||
viewContext.Writer = sw;
|
// Hand the model to the view through ViewData so the template's
|
||||||
// write the render view with the given context to the stringwriter
|
// @model directive resolves it (the previous implementation never
|
||||||
viewResult.View.RenderAsync(viewContext).Wait();
|
// set ViewData.Model, leaving Model null inside the template).
|
||||||
|
controller.ViewData.Model = model;
|
||||||
|
|
||||||
|
using (var sw = new StringWriter())
|
||||||
|
{
|
||||||
|
var viewContext = new ViewContext(
|
||||||
|
actionContext,
|
||||||
|
viewResult.View,
|
||||||
|
controller.ViewData,
|
||||||
|
controller.TempData,
|
||||||
|
sw,
|
||||||
|
new HtmlHelperOptions());
|
||||||
|
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
||||||
return sw.GetStringBuilder().ToString();
|
return sw.GetStringBuilder().ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,11 @@ internal class Program
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.AddYavscCors(builder.Configuration)
|
.AddYavscCors(builder.Configuration)
|
||||||
.AddControllers();
|
.AddControllersWithViews();
|
||||||
|
|
||||||
|
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
|
||||||
|
// the document-generation flow and BillingController.
|
||||||
|
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
|
||||||
|
|
||||||
// accepts any access token issued by identity server
|
// accepts any access token issued by identity server
|
||||||
services.AddAuthentication("Bearer")
|
services.AddAuthentication("Bearer")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@model IBillable
|
@model IBillable
|
||||||
@{
|
@{
|
||||||
Layout = "null";
|
Layout = null;
|
||||||
var pro = ViewBag.PerformerProfile;
|
var pro = ViewBag.PerformerProfile;
|
||||||
var from = ViewBag.Performer;
|
var from = ViewBag.Performer;
|
||||||
var to = ViewBag.Client;
|
var to = ViewBag.Client;
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@model Estimate
|
@model Estimate
|
||||||
@{
|
@{
|
||||||
Layout = "null";
|
Layout = null;
|
||||||
var pro = Model.Query.PerformerProfile;
|
var pro = Model.Query.PerformerProfile;
|
||||||
var from = Model.Query.PerformerProfile.Performer;
|
var from = Model.Query.PerformerProfile.Performer;
|
||||||
var to = Model.Query.Client;
|
var to = Model.Query.Client;
|
||||||
12
src/Yavsc.Api/Views/_ViewImports.cshtml
Normal file
12
src/Yavsc.Api/Views/_ViewImports.cshtml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
@using Yavsc
|
||||||
|
@using Yavsc.Helpers
|
||||||
|
@using Yavsc.Billing
|
||||||
|
@using Yavsc.Models
|
||||||
|
@using Yavsc.Models.Billing
|
||||||
|
@using Yavsc.ViewModels.Gen
|
||||||
|
@using Microsoft.AspNetCore.Mvc
|
||||||
|
@using Microsoft.AspNetCore.Html
|
||||||
|
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<SiteSettings> SiteSettings
|
||||||
|
|
@ -8,7 +8,6 @@ namespace Yavsc.Controllers
|
||||||
using Helpers;
|
using Helpers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Models;
|
using Models;
|
||||||
using ViewModels.FrontOffice;
|
using ViewModels.FrontOffice;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -21,22 +20,20 @@ namespace Yavsc.Controllers
|
||||||
readonly ILogger _logger;
|
readonly ILogger _logger;
|
||||||
readonly IStringLocalizer _SR;
|
readonly IStringLocalizer _SR;
|
||||||
private readonly IBillingService _billing;
|
private readonly IBillingService _billing;
|
||||||
private readonly SiteSettings _siteSettings;
|
|
||||||
|
|
||||||
public FrontOfficeController(ApplicationDbContext context,
|
public FrontOfficeController(ApplicationDbContext context,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IBillingService billing,
|
IBillingService billing,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IStringLocalizer<FrontOfficeController> SR,
|
IStringLocalizer<FrontOfficeController> SR)
|
||||||
IOptions<SiteSettings> siteSettings)
|
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
||||||
_SR = SR;
|
_SR = SR;
|
||||||
_billing = billing;
|
_billing = billing;
|
||||||
_siteSettings = siteSettings.Value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ActionResult Index()
|
public ActionResult Index()
|
||||||
{
|
{
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -80,61 +77,5 @@ namespace Yavsc.Controllers
|
||||||
return View(result);
|
return View(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Produces("text/x-tex"), Authorize, Route("estimate-{id}.tex")]
|
|
||||||
[HttpGet]
|
|
||||||
public ViewResult EstimateTex(long id)
|
|
||||||
{
|
|
||||||
var estimate = _context.Estimates.Include(x => x.Query)
|
|
||||||
.Include(x => x.Query.Client)
|
|
||||||
.Include(x => x.Query.PerformerProfile)
|
|
||||||
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
|
|
||||||
.Include(x => x.Query.PerformerProfile.Performer)
|
|
||||||
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
|
|
||||||
Response.ContentType = "text/x-tex";
|
|
||||||
return View("Estimate.tex", estimate);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize, Route("Estimate-{id}.pdf")]
|
|
||||||
[HttpGet]
|
|
||||||
public IActionResult EstimatePdf(long id)
|
|
||||||
{
|
|
||||||
ViewBag.TempDir = _siteSettings.TempDir;
|
|
||||||
ViewBag.BillsDir = _siteSettings.Bills;
|
|
||||||
var estimate = _context.Estimates.Include(x => x.Query)
|
|
||||||
.Include(x => x.Query.Client)
|
|
||||||
.Include(x => x.Query.PerformerProfile)
|
|
||||||
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
|
|
||||||
.Include(x => x.Query.PerformerProfile.Performer)
|
|
||||||
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
|
|
||||||
if (estimate == null)
|
|
||||||
throw new Exception("No data");
|
|
||||||
return View("Estimate.pdf", estimate);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult EstimateProValidation()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult EstimateClientValidation()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
|
|
||||||
}
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult BillValidation()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
|
|
||||||
}
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult BillAcquitment()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
|
|
||||||
@model Estimate
|
|
||||||
<div class="estimate">
|
|
||||||
|
|
||||||
<a href="~/api/pdfbill/estimate-@(Model.Id).tex" >Export au format LaTeX</a>
|
|
||||||
|
|
||||||
|
|
||||||
<form action="~/api/pdfbill/gen/@Model.Id" method="POST">
|
|
||||||
<input type="submit" value="Générer le Pdf"/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<a href="~/api/pdfbill/get/@(Model.Id)" >Télécharger le document généré</a>
|
|
||||||
</div>
|
|
||||||
|
|
@ -34,7 +34,7 @@ namespace Yavsc.Server.Helpers
|
||||||
{
|
{
|
||||||
var item = new FileReceivedInfo(
|
var item = new FileReceivedInfo(
|
||||||
settings.Bills,
|
settings.Bills,
|
||||||
AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId));
|
AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId));
|
||||||
|
|
||||||
var fi = new FileInfo(item.FullName);
|
var fi = new FileInfo(item.FullName);
|
||||||
if (fi.Exists) item.Overridden = true;
|
if (fi.Exists) item.Overridden = true;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ namespace Yavsc.Models.Billing
|
||||||
{
|
{
|
||||||
using Models.Workflow;
|
using Models.Workflow;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
public partial class Estimate : IEstimate
|
public partial class Estimate : IEstimate
|
||||||
{
|
{
|
||||||
|
|
@ -78,14 +79,39 @@ namespace Yavsc.Models.Billing
|
||||||
public DateTime ClientValidationDate { get; set; }
|
public DateTime ClientValidationDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All signatures captured against this estimate, in
|
/// All signatures captured against this estimate, keyed by
|
||||||
/// capture order. Multiple versions per (Type, SignerId)
|
/// <see cref="SignatureType"/>. The persister upserts by
|
||||||
/// are allowed; the controller reads the most recent
|
/// (EstimateId, Type), so there is at most one row per type.
|
||||||
/// when asked. See <see cref="Signature"/>.
|
/// This collection is the persisted shape and stays
|
||||||
|
/// <c>[JsonIgnore]</c>: the wire payload exposes signatures
|
||||||
|
/// only through the dedicated <see cref="SignaturePro"/> and
|
||||||
|
/// <see cref="SignatureClient"/> projections, since an
|
||||||
|
/// estimate has exactly these two signature slots and never
|
||||||
|
/// more. See <see cref="Signature"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[InverseProperty(nameof(Signature.Estimate))]
|
[InverseProperty(nameof(Signature.Estimate))]
|
||||||
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
public virtual ICollection<Signature> Signatures { get; set; }
|
public virtual ICollection<Signature> Signatures { get; set; }
|
||||||
= new List<Signature>();
|
= new List<Signature>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The provider's signature on this estimate, or null when the
|
||||||
|
/// provider has not signed. Projected from
|
||||||
|
/// <see cref="Signatures"/>; not mapped (the table is
|
||||||
|
/// <see cref="Signatures"/>).
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public Signature? SignaturePro
|
||||||
|
=> Signatures?.FirstOrDefault(s => s.Type == SignatureType.Pro);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client's signature on this estimate, or null when the
|
||||||
|
/// client has not signed. Projected from
|
||||||
|
/// <see cref="Signatures"/>; not mapped.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public Signature? SignatureClient
|
||||||
|
=> Signatures?.FirstOrDefault(s => s.Type == SignatureType.Client);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue