Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on ProviderValidationDate so a provider's signed estimate leaves their list; PostIt refreshes the list on navigation pop-back. - Title renames: "Devis en attente de signature fournisseur/client" on both the provider and client sides (HomePage, VM, tests). - Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent, Bill/Estimate templates, _ViewImports). Program.cs wires AddControllersWithViews + SiteSettings; FrontOfficeApiController injects IRazorViewEngine (cast to IViewEngine) with optional deps and exposes estimate.tex/estimate.pdf endpoints keyed by query id. - PDF generation drives through the Estimate_pdf view (side-effect render) and compiles via the system lualatex, fed TeX on stdin (no intermediate .tex), PDF to the bills dir, aux artifacts cleaned so only the .pdf persists. Resolve the bills dir to an absolute path so lualatex's -output-directory and working directory agree regardless of the host cwd (fixes "I can't write on file estimate-1.log"). - PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync; Télécharger PDF/TeX buttons on the validation and command-detail pages via a StorageProvider save helper. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
70a123b840
commit
97d0584292
41 changed files with 1036 additions and 290 deletions
|
|
@ -17,7 +17,7 @@ public class EstimateListPageViewModelTests
|
|||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
||||
Assert.Equal("Mes devis à valider", vm.Title);
|
||||
Assert.Equal("Devis en attente de signature client", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
// Most recent first
|
||||
Assert.Equal(2, vm.Estimates[0].Id);
|
||||
|
|
@ -35,7 +35,7 @@ public class EstimateListPageViewModelTests
|
|||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
||||
Assert.Equal("Mes devis en attente", vm.Title);
|
||||
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ using System.Net.Http;
|
|||
using PostIt.Controls;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the
|
||||
/// signature pad + perspective into the right
|
||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
|
||||
/// signature pads (provider + client) into the right
|
||||
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
||||
/// is not tested here; the VM is driven through the same
|
||||
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
||||
|
|
@ -46,9 +47,10 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
DrawOneStroke(pad);
|
||||
var proPad = new SignaturePadControl();
|
||||
var clientPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, clientPad);
|
||||
DrawOneStroke(proPad); // provider signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
|
|
@ -60,6 +62,28 @@ public class EstimateValidationPageViewModelTests
|
|||
Assert.NotNull(body.Strokes);
|
||||
Assert.NotEmpty(body.Strokes);
|
||||
Assert.Equal(10_000, body.CoordinateMax);
|
||||
// The provider perspective declares the Pro side, so the
|
||||
// server stores the signature under the provider's author
|
||||
// regardless of role inference.
|
||||
Assert.Equal(SignatureType.Pro, body.SignatureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_from_client_perspective_declares_client_side()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(new SignaturePadControl(), cliPad);
|
||||
DrawOneStroke(cliPad); // client signs on their writable pad
|
||||
|
||||
await vm.ValidateAsync();
|
||||
|
||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
||||
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -70,7 +94,7 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl());
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
|
||||
|
||||
await vm.RejectAsync();
|
||||
|
||||
|
|
@ -88,7 +112,7 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
|
||||
vm.Attach(new SignaturePadControl()); // empty pad
|
||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
|
||||
|
||||
await vm.ValidateAsync(); // no throw: returns before GoBack
|
||||
|
||||
|
|
@ -104,19 +128,19 @@ public class EstimateValidationPageViewModelTests
|
|||
var vm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
var proPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, new SignaturePadControl());
|
||||
|
||||
// No CommandId ⇒ cannot validate even after signing.
|
||||
DrawOneStroke(pad);
|
||||
DrawOneStroke(proPad);
|
||||
Assert.False(vm.ValidateCommand.CanExecute(null));
|
||||
|
||||
var vm2 = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var pad2 = new SignaturePadControl();
|
||||
vm2.Attach(pad2);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
vm2.Attach(proPad2, new SignaturePadControl());
|
||||
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
||||
DrawOneStroke(pad2);
|
||||
DrawOneStroke(proPad2);
|
||||
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
|
|
@ -134,48 +158,67 @@ public class EstimateValidationPageViewModelTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_signature_renders_loaded_strokes_in_pad()
|
||||
public void Only_the_author_pad_is_writable()
|
||||
{
|
||||
var strokes = new[] { 1, 5000, 5000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.Signatures = new List<EstimateSignatureDto>
|
||||
{
|
||||
new() { Type = 0, Strokes = strokes, CoordinateMax = 10_000 },
|
||||
};
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
|
||||
var pad = new SignaturePadControl();
|
||||
vm.Attach(pad);
|
||||
var proVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
proVm.Attach(proPad, cliPad);
|
||||
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
|
||||
Assert.True(cliPad.IsReadOnly);
|
||||
|
||||
var snap = pad.Snapshot();
|
||||
Assert.False(snap.IsEmpty);
|
||||
Assert.Equal(strokes, snap.Strokes);
|
||||
Assert.True(vm.HasSignature);
|
||||
var cliVm = new EstimateValidationPageViewModel(
|
||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
||||
var proPad2 = new SignaturePadControl();
|
||||
var cliPad2 = new SignaturePadControl();
|
||||
cliVm.Attach(proPad2, cliPad2);
|
||||
Assert.True(proPad2.IsReadOnly);
|
||||
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_signature_picks_side_by_perspective()
|
||||
public void Load_existing_signatures_renders_both_sides_in_their_pads()
|
||||
{
|
||||
var proStrokes = new[] { 1, 1000, 1000 };
|
||||
var cliStrokes = new[] { 1, 9000, 9000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.Signatures = new List<EstimateSignatureDto>
|
||||
{
|
||||
new() { Type = 0, Strokes = proStrokes },
|
||||
new() { Type = 1, Strokes = cliStrokes },
|
||||
};
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
|
||||
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
|
||||
|
||||
// Client perspective: both pads show the stored signatures, but
|
||||
// only the client pad is writable.
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
||||
var proPad = new SignaturePadControl();
|
||||
var cliPad = new SignaturePadControl();
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.True(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_existing_pro_signature_renders_in_pro_pad_only()
|
||||
{
|
||||
var strokes = new[] { 1, 5000, 5000 };
|
||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
||||
var estimate = SampleEstimate();
|
||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
|
||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
|
||||
var proPad = new SignaturePadControl();
|
||||
var proVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
||||
proVm.Attach(proPad);
|
||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
||||
|
||||
var cliPad = new SignaturePadControl();
|
||||
var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
||||
cliVm.Attach(cliPad);
|
||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
||||
vm.Attach(proPad, cliPad);
|
||||
|
||||
Assert.Equal(strokes, proPad.Snapshot().Strokes);
|
||||
Assert.True(cliPad.Snapshot().IsEmpty);
|
||||
Assert.True(vm.HasProSignature);
|
||||
Assert.False(vm.HasClientSignature);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
|
|
|
|||
|
|
@ -84,6 +84,34 @@ public class FrontOfficeApiClientTests
|
|||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimatePdfAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.pdf",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||
|
||||
await client.GetEstimateTexAsync(42);
|
||||
|
||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
||||
Assert.Equal(
|
||||
"https://business.example/api/v1/front/query/42/estimate.tex",
|
||||
api.LastPath);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
|
|
@ -116,6 +144,14 @@ public class FrontOfficeApiClientTests
|
|||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = null;
|
||||
return Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
|
|||
set => SetValue(CaptureAreaProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true the control ignores pointer input — it only displays
|
||||
/// strokes loaded into its buffer (e.g. the other party's
|
||||
/// signature, which must be shown but not editable). <see cref="Load"/>
|
||||
/// and <see cref="Clear"/> still work.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> IsReadOnlyProperty =
|
||||
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => GetValue(IsReadOnlyProperty);
|
||||
set => SetValue(IsReadOnlyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captured strokes in wire form. Exposed as a read-only view
|
||||
/// over the internal buffer. The buffer only mutates on the UI
|
||||
|
|
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
|
|||
|
||||
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
||||
e.Pointer.Capture(CaptureArea);
|
||||
_capturing = true;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ public partial class SignaturePadView : UserControl
|
|||
private const double StrokeThickness = 2.0;
|
||||
private const double CoordinateMax = 10_000.0;
|
||||
|
||||
// Last size the ink layer was painted at. LayoutUpdated repaints
|
||||
// only when this changes, so adding/removing polyline children
|
||||
// (which invalidates layout) does not loop.
|
||||
private Size _lastPaintedSize;
|
||||
|
||||
public static readonly StyledProperty<double> CaptureWidthProperty =
|
||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
||||
|
||||
|
|
@ -71,6 +76,19 @@ public partial class SignaturePadView : UserControl
|
|||
ApplyFrameSize();
|
||||
|
||||
InnerPad.RedrawRequested += OnPadRedrawRequested;
|
||||
// Repaint once layout completes so an ink buffer loaded before
|
||||
// the first arrange (e.g. an existing signature on reopen)
|
||||
// still renders without waiting for a pointer event. The
|
||||
// handler repaints only on a size change to avoid a layout
|
||||
// loop (child changes invalidate layout → LayoutUpdated).
|
||||
LayoutUpdated += OnLayoutUpdated;
|
||||
}
|
||||
|
||||
private void OnLayoutUpdated(object? sender, EventArgs e)
|
||||
{
|
||||
var size = EffectivePadSize();
|
||||
if (size != _lastPaintedSize)
|
||||
Repaint();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
|
|
@ -101,14 +119,34 @@ public partial class SignaturePadView : UserControl
|
|||
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
||||
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
|
||||
|
||||
/// <summary>
|
||||
/// The effective ink surface size: the arranged bounds when
|
||||
/// available, otherwise the explicitly sized
|
||||
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
|
||||
/// latter covers a repaint triggered before the first layout pass
|
||||
/// (e.g. loading an existing signature on reopen), when Bounds is
|
||||
/// still empty.
|
||||
/// </summary>
|
||||
private Size EffectivePadSize()
|
||||
{
|
||||
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
|
||||
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
|
||||
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
|
||||
return default;
|
||||
return new Size(w, h);
|
||||
}
|
||||
|
||||
private void Repaint()
|
||||
{
|
||||
if (InkLayer is null) return;
|
||||
|
||||
var size = EffectivePadSize();
|
||||
if (size == default) return;
|
||||
_lastPaintedSize = size;
|
||||
|
||||
InkLayer.Children.Clear();
|
||||
var w = PadFrame.Bounds.Width;
|
||||
var h = PadFrame.Bounds.Height;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
var w = size.Width;
|
||||
var h = size.Height;
|
||||
|
||||
var pending = InnerPad.PendingStroke;
|
||||
var strokes = InnerPad.Strokes;
|
||||
|
|
|
|||
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PostIt.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Saves a downloaded byte payload to a file chosen by the user through
|
||||
/// the platform save picker. This is the first file-save (as opposed to
|
||||
/// file-open) helper in PostIt; the open picker is already used for blog
|
||||
/// attachment and avatar uploads
|
||||
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
|
||||
///
|
||||
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
|
||||
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
|
||||
/// both the desktop and single-view lifetimes.</para>
|
||||
/// </summary>
|
||||
public static class FileSaveHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Prompt the user to choose a destination and write
|
||||
/// <paramref name="content"/> to it. Returns false when the user
|
||||
/// cancels the picker; throws on IO/platform errors so the caller
|
||||
/// can surface them.
|
||||
/// </summary>
|
||||
public static async Task<bool> SaveAsync(
|
||||
string suggestedName,
|
||||
string defaultExtension,
|
||||
byte[] content,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var view = (Application.Current as App)?.View;
|
||||
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
|
||||
if (topLevel is null)
|
||||
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
|
||||
|
||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Enregistrer sous",
|
||||
DefaultExtension = defaultExtension,
|
||||
SuggestedFileName = suggestedName,
|
||||
FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
|
||||
{
|
||||
Patterns = new[] { $"*.{defaultExtension}" }
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
return false; // user cancelled
|
||||
|
||||
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
|
||||
await stream.WriteAsync(content, ct).ConfigureAwait(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
return "Avatar mis à jour.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
|
||||
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
|
||||
/// which JSON-decodes the response, this returns the raw response
|
||||
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
|
||||
/// contract as <see cref="SetAvatarAsync"/>.
|
||||
/// </summary>
|
||||
public virtual async Task<byte[]> DownloadAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_tokens is null)
|
||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
||||
|
||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var sendOnce = async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
var response = await sendOnce().ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
|
|
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
try
|
||||
{
|
||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
Activity,
|
||||
Performer,
|
||||
Form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
details,
|
||||
IsReadOnly);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace PostIt.ViewModels;
|
|||
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly BillingApiClient _billingClient;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private readonly BillingQueryDetailsDto _details;
|
||||
|
||||
public ActivityInfo Activity { get; }
|
||||
|
|
@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
ActivityUserDisplayItem performer,
|
||||
CommandFormSummary form,
|
||||
BillingApiClient billingClient,
|
||||
FrontOfficeApiClient frontClient,
|
||||
BillingQueryDetailsDto details,
|
||||
bool isReadOnly)
|
||||
{
|
||||
|
|
@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||
IsReadOnly = isReadOnly;
|
||||
|
||||
|
|
@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a LaTeX source
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadTexAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimateTexAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a compiled PDF
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
|
||||
/// <c>texi2pdf</c> on the server host.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadPdfAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimatePdfAsync(_details.Id),
|
||||
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await fetch().ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved
|
||||
? $"Fichier .{ext} enregistré."
|
||||
: "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
||||
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ namespace PostIt.ViewModels;
|
|||
/// </summary>
|
||||
public enum EstimateListPerspective
|
||||
{
|
||||
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
|
||||
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
|
||||
Client,
|
||||
|
||||
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
|
||||
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
|
||||
Provider,
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +40,8 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
|||
public EstimateListPerspective Perspective => _perspective;
|
||||
|
||||
public string Title => _perspective == EstimateListPerspective.Client
|
||||
? "Mes devis à valider"
|
||||
: "Mes devis en attente";
|
||||
? "Devis en attente de signature client"
|
||||
: "Devis en attente de signature fournisseur";
|
||||
|
||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||
? "Point de vue client"
|
||||
|
|
|
|||
|
|
@ -10,27 +10,34 @@ using PostIt.Controls;
|
|||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Validation (accept / reject) of a single estimate, with an optional
|
||||
/// signature. Backs the estimate validation page opened from
|
||||
/// Validation (accept / reject) of a single estimate, with optional
|
||||
/// signatures. Backs the estimate validation page opened from
|
||||
/// <see cref="EstimateListPageViewModel"/> for either perspective.
|
||||
///
|
||||
/// The server (<c>POST api/v1/front/query/accept</c>) resolves the
|
||||
/// caller's role from the authenticated user compared to the query's
|
||||
/// <c>PerformerId</c>/<c>ClientId</c>; the perspective passed in here
|
||||
/// is only a UI hint (the <see cref="RoleLabel"/>). The billing code
|
||||
/// sent to the server is the estimate's <see cref="EstimateDto.CommandType"/>
|
||||
/// and the query id is its <see cref="EstimateDto.CommandId"/>.
|
||||
/// <para>Two signatures are shown, each in its own pad: the provider's
|
||||
/// (Pro) and the client's (Client). Both are rendered from the estimate
|
||||
/// payload when present, so reopening a partly-signed estimate shows
|
||||
/// what was already captured. Only the pad matching the current
|
||||
/// <see cref="EstimateListPerspective"/> is writable — the server
|
||||
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
|
||||
/// same rule (it resolves the caller's role from the authenticated user
|
||||
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
|
||||
/// the other pad read-only. The billing code sent to the server is the
|
||||
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
|
||||
/// its <see cref="EstimateDto.CommandId"/>.</para>
|
||||
/// </summary>
|
||||
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly EstimateDto _estimate;
|
||||
private readonly EstimateListPerspective _perspective;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private SignaturePadControl? _pad;
|
||||
private SignaturePadControl? _proPad;
|
||||
private SignaturePadControl? _clientPad;
|
||||
|
||||
public string Title => _estimate.Title;
|
||||
public string Description => _estimate.Description;
|
||||
|
|
@ -44,9 +51,22 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
? "Vous validez en tant que prestataire"
|
||||
: "Vous validez en tant que client";
|
||||
|
||||
/// <summary>
|
||||
/// True when the current user may sign the provider's signature
|
||||
/// (i.e. they opened the list as the provider). The other party's
|
||||
/// pad is read-only.
|
||||
/// </summary>
|
||||
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
|
||||
|
||||
/// <summary>
|
||||
/// True when the current user may sign the client's signature
|
||||
/// (i.e. they opened the list as the client).
|
||||
/// </summary>
|
||||
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of who has already validated this
|
||||
/// estimate, shown above the signature pad.
|
||||
/// estimate, shown above the signature pads.
|
||||
/// </summary>
|
||||
public string ValidationStateText
|
||||
{
|
||||
|
|
@ -78,15 +98,29 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||
|
||||
/// <summary>Stroke/point counts for the writable pad.</summary>
|
||||
[ObservableProperty]
|
||||
public partial int StrokeCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int PointCount { get; set; }
|
||||
|
||||
/// <summary>True when the writable pad holds a signature.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool HasSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasProSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasClientSignature { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ProSignatureHint { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ClientSignatureHint { get; set; } = string.Empty;
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
get => false;
|
||||
|
|
@ -110,58 +144,80 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind the page's signature pad control (the inner control of the
|
||||
/// page's <see cref="SignaturePadView"/>). Called from the view's
|
||||
/// code-behind once the view is in the visual tree. Also calls
|
||||
/// <see cref="LoadExistingSignature"/> so a previously drawn
|
||||
/// signature — carried on the estimate payload — is rendered back
|
||||
/// into the pad on reopen.
|
||||
/// Bind the page's two signature pads (the inner controls of the
|
||||
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
|
||||
/// the client's pad. Called from the view's code-behind once the
|
||||
/// view is in the visual tree. Marks the non-author pad read-only,
|
||||
/// then renders any signatures already carried on the estimate
|
||||
/// payload.
|
||||
/// </summary>
|
||||
public void Attach(SignaturePadControl pad)
|
||||
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
|
||||
{
|
||||
if (pad is null) throw new ArgumentNullException(nameof(pad));
|
||||
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
|
||||
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
|
||||
Detach();
|
||||
_pad = pad;
|
||||
_pad.RedrawRequested += OnPadRedraw;
|
||||
_proPad = proPad;
|
||||
_clientPad = clientPad;
|
||||
_proPad.IsReadOnly = !CanWritePro;
|
||||
_clientPad.IsReadOnly = !CanWriteClient;
|
||||
_proPad.RedrawRequested += OnPadRedraw;
|
||||
_clientPad.RedrawRequested += OnPadRedraw;
|
||||
LoadExistingSignatures();
|
||||
RefreshFromPad();
|
||||
LoadExistingSignature();
|
||||
}
|
||||
|
||||
public void Detach()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
_pad.RedrawRequested -= OnPadRedraw;
|
||||
_pad = null;
|
||||
if (_proPad is not null)
|
||||
_proPad.RedrawRequested -= OnPadRedraw;
|
||||
if (_clientPad is not null)
|
||||
_clientPad.RedrawRequested -= OnPadRedraw;
|
||||
_proPad = null;
|
||||
_clientPad = null;
|
||||
}
|
||||
|
||||
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
|
||||
|
||||
/// <summary>The pad the current user may sign in.</summary>
|
||||
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
|
||||
|
||||
/// <summary>
|
||||
/// Render the signature already stored for this side of the
|
||||
/// estimate back into the pad. The strokes are carried on the
|
||||
/// estimate payload (<see cref="EstimateDto.Signatures"/>); the
|
||||
/// side is chosen by the current <see cref="_perspective"/> — the
|
||||
/// provider reopens to see their "Pro" signature, the client their
|
||||
/// "Client" signature. No network round-trip: the payload is used
|
||||
/// as-is, in the same wire format PostIt captured.
|
||||
/// Render the signatures already stored for this estimate back into
|
||||
/// their pads. The strokes are carried on the estimate payload as
|
||||
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
|
||||
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
|
||||
/// its own side directly, in the same wire format PostIt captured.
|
||||
/// No network round-trip.
|
||||
/// </summary>
|
||||
public void LoadExistingSignature()
|
||||
public void LoadExistingSignatures()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
// SignatureType.Pro = 0, SignatureType.Client = 1 (server enum).
|
||||
int wanted = _perspective == EstimateListPerspective.Provider ? 0 : 1;
|
||||
var sig = _estimate.Signatures?.FirstOrDefault(s => s.Type == wanted);
|
||||
if (sig?.Strokes is { Length: > 0 } strokes)
|
||||
{
|
||||
_pad.Load(strokes);
|
||||
RefreshFromPad();
|
||||
}
|
||||
LoadSide(_proPad, _estimate.SignaturePro);
|
||||
LoadSide(_clientPad, _estimate.SignatureClient);
|
||||
RefreshFromPad();
|
||||
}
|
||||
|
||||
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
|
||||
{
|
||||
if (pad is null || sig is null) return;
|
||||
if (sig.Strokes is { Length: > 0 } strokes)
|
||||
pad.Load(strokes);
|
||||
}
|
||||
|
||||
private void RefreshFromPad()
|
||||
{
|
||||
if (_pad is null) return;
|
||||
var snap = _pad.Snapshot();
|
||||
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
|
||||
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
|
||||
|
||||
ProSignatureHint = CanWritePro
|
||||
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
|
||||
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
|
||||
ClientSignatureHint = CanWriteClient
|
||||
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
|
||||
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
|
||||
|
||||
var pad = WritablePad;
|
||||
if (pad is null) return;
|
||||
var snap = pad.Snapshot();
|
||||
StrokeCount = snap.StrokeCount;
|
||||
PointCount = snap.PointCount;
|
||||
HasSignature = !snap.IsEmpty;
|
||||
|
|
@ -188,13 +244,14 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
|
||||
return;
|
||||
}
|
||||
if (_pad is null || _pad.Snapshot().IsEmpty)
|
||||
var pad = WritablePad;
|
||||
if (pad is null || pad.Snapshot().IsEmpty)
|
||||
{
|
||||
this.SetWarningStatus("Veuillez signer avant de valider.");
|
||||
return;
|
||||
}
|
||||
|
||||
var snap = _pad.Snapshot();
|
||||
var snap = pad.Snapshot();
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
|
|
@ -203,6 +260,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
Strokes = snap.Strokes,
|
||||
CoordinateMax = SignaturePadData.CoordinateMax,
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
// Declare which side the caller is signing as, so the
|
||||
// server stores the signature under the right author
|
||||
// (Pro/Client) even when one user is both parties.
|
||||
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
|
||||
};
|
||||
|
||||
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
|
||||
|
|
@ -264,10 +325,65 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
|
|||
[RelayCommand]
|
||||
public void ClearSignature()
|
||||
{
|
||||
_pad?.Clear();
|
||||
WritablePad?.Clear();
|
||||
RefreshFromPad();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate as a LaTeX source document. The server
|
||||
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
|
||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
|
||||
/// user picks a destination via the platform save picker.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadTexAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
|
||||
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate as a compiled PDF
|
||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
|
||||
/// Requires <c>texi2pdf</c> on the server host; a failure is
|
||||
/// surfaced as a status message.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
public async Task DownloadPdfAsync()
|
||||
{
|
||||
await DownloadAsync(
|
||||
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
|
||||
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
||||
{
|
||||
if (_estimate.CommandId is not long)
|
||||
{
|
||||
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await fetch().ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved
|
||||
? $"Fichier .{ext} enregistré."
|
||||
: "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
|
|
@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
|||
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
||||
.ConfigureAwait(true);
|
||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
activity,
|
||||
performer,
|
||||
form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
details,
|
||||
isReadOnly: false);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
|
||||
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
|
||||
<Button Grid.Column="0"
|
||||
Content="Retour"
|
||||
Command="{Binding BackCommand}" />
|
||||
|
|
@ -22,6 +22,12 @@
|
|||
Content="Ouvrir en edition"
|
||||
Command="{Binding OpenEditorCommand}"
|
||||
IsVisible="{Binding CanEdit}" />
|
||||
<Button Grid.Column="4"
|
||||
Content="Télécharger PDF"
|
||||
Command="{Binding DownloadPdfCommand}" />
|
||||
<Button Grid.Column="6"
|
||||
Content="Télécharger TeX"
|
||||
Command="{Binding DownloadTexCommand}" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -14,4 +15,19 @@ public partial class EstimateListPage : ContentPage
|
|||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
// Re-fetch the list when returning to it (e.g. after validating an
|
||||
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
|
||||
// fires on the page popped back to with NavigationType.Pop; the list
|
||||
// VM is retained on the nav stack while away, so this refresh picks
|
||||
// up the server-side filter change that removed the signed estimate.
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
if (e.NavigationType == NavigationType.Pop
|
||||
&& DataContext is EstimateListPageViewModel vm)
|
||||
{
|
||||
_ = vm.RefreshAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,22 +82,38 @@
|
|||
CornerRadius="8"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Signature" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Signez ci-dessous pour valider ce devis."
|
||||
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ProSignatureHint}"
|
||||
FontSize="11"
|
||||
Opacity="0.7" />
|
||||
<controls:SignaturePadView x:Name="Pad"
|
||||
<controls:SignaturePadView x:Name="ProPad"
|
||||
CaptureWidth="560"
|
||||
CaptureHeight="200" />
|
||||
<TextBlock FontSize="11"
|
||||
Foreground="DarkSlateGray">
|
||||
<Run Text="Traits: " />
|
||||
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
|
||||
<Run Text=" · Points: " />
|
||||
<Run Text="{Binding PointCount, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
CaptureHeight="160" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="8"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ClientSignatureHint}"
|
||||
FontSize="11"
|
||||
Opacity="0.7" />
|
||||
<controls:SignaturePadView x:Name="ClientPad"
|
||||
CaptureWidth="560"
|
||||
CaptureHeight="160" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock FontSize="11"
|
||||
Foreground="DarkSlateGray">
|
||||
<Run Text="Votre signature : traits " />
|
||||
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
|
||||
<Run Text=" · points " />
|
||||
<Run Text="{Binding PointCount, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
|
@ -109,6 +125,10 @@
|
|||
Command="{Binding ValidateCommand}" />
|
||||
<Button Content="Refuser"
|
||||
Command="{Binding RejectCommand}" />
|
||||
<Button Content="Télécharger PDF"
|
||||
Command="{Binding DownloadPdfCommand}" />
|
||||
<Button Content="Télécharger TeX"
|
||||
Command="{Binding DownloadTexCommand}" />
|
||||
<Button Content="Retour"
|
||||
Command="{Binding BackCommand}" />
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ public partial class EstimateValidationPage : ContentPage
|
|||
|
||||
_vm = vm;
|
||||
|
||||
if (_vm is null || Pad is null) return;
|
||||
if (_vm is null || ProPad is null || ClientPad is null) return;
|
||||
|
||||
_vm.Attach(Pad.SignaturePad);
|
||||
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,11 +26,11 @@
|
|||
Command="{Binding OpenProviderRequests}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis à valider"
|
||||
<Button Content="Devis en attente de signature client"
|
||||
Command="{Binding OpenClientEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis en attente"
|
||||
<Button Content="Devis en attente de signature fournisseur"
|
||||
Command="{Binding OpenProviderEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ public enum SignatureType
|
|||
{
|
||||
Pro = 0,
|
||||
Client = 1,
|
||||
}
|
||||
}
|
||||
|
|
@ -41,32 +41,34 @@ public sealed class EstimateDto
|
|||
public DateTime ClientValidationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures already captured against this estimate (Pro and/or
|
||||
/// Client), as returned with the estimate payload. Each carries the
|
||||
/// PostIt wire-format <see cref="EstimateSignatureDto.Strokes"/>
|
||||
/// so the pad can repaint a previously drawn signature on reopen
|
||||
/// without a second round-trip. Null/empty when nobody has signed.
|
||||
/// The provider's signature on this estimate, or null when the
|
||||
/// provider has not signed. Carries the PostIt wire-format
|
||||
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
|
||||
/// repaint a previously drawn signature on reopen without a second
|
||||
/// round-trip.
|
||||
/// </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>
|
||||
/// One stored signature on an estimate, mirroring the JSON shape of
|
||||
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
|
||||
/// <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>
|
||||
public sealed class EstimateSignatureDto
|
||||
{
|
||||
public long Id { 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 DateTime CapturedAtUtc { get; set; }
|
||||
public int[]? Strokes { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
|
|
@ -17,6 +18,17 @@ public sealed class QueryAcceptanceRequestDto
|
|||
public int CoordinateMax { get; set; } = 10_000;
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,33 @@ public sealed class FrontOfficeApiClient
|
|||
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 Uri ResolveBusinessBaseAddress()
|
||||
|
|
|
|||
|
|
@ -73,4 +73,23 @@ public interface IYavscApiClient : IAsyncDisposable
|
|||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
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]
|
||||
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();
|
||||
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
|
||||
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
|
||||
// Not yet provider-signed: visible in the provider's "awaiting
|
||||
// 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");
|
||||
|
||||
|
|
@ -300,7 +303,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
|||
using var doc = JsonDocument.Parse(body);
|
||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
||||
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.Single(item.GetProperty("bill").EnumerateArray());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,19 +45,26 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
|
||||
private static HttpContent NoSignature() => JsonBody(new { });
|
||||
|
||||
private static HttpContent WithSignature() =>
|
||||
JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
|
||||
private static HttpContent WithSignature(SignatureType? type = null) =>
|
||||
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) =>
|
||||
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an Rdv query with distinct parties (provider alice,
|
||||
/// client bob) and, when <paramref name="withEstimate"/> is true,
|
||||
/// an estimate linked to it via CommandId. Returns the query id
|
||||
/// and (when seeded) the estimate id.
|
||||
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
|
||||
/// true, an estimate linked to it via CommandId. The provider is
|
||||
/// <paramref name="performer"/> (default alice) and the client is
|
||||
/// <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>
|
||||
private (long queryId, long estimateId) SeedQuery(bool withEstimate)
|
||||
private (long queryId, long estimateId) SeedQuery(
|
||||
bool withEstimate,
|
||||
string performer = "alice",
|
||||
string client = "bob")
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
|
@ -70,11 +77,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var query = new RdvQuery
|
||||
{
|
||||
ActivityCode = "dev",
|
||||
ClientId = "bob",
|
||||
PerformerId = "alice",
|
||||
ClientId = client,
|
||||
PerformerId = performer,
|
||||
Consent = true,
|
||||
UserCreated = "alice",
|
||||
UserModified = "alice",
|
||||
UserCreated = performer,
|
||||
UserModified = performer,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
EventDate = DateTime.UtcNow.AddDays(1),
|
||||
|
|
@ -91,8 +98,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var estimate = new Estimate
|
||||
{
|
||||
CommandId = query.Id,
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
ClientId = client,
|
||||
OwnerId = performer,
|
||||
CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis prestation",
|
||||
Description = "Devis de test",
|
||||
|
|
@ -271,11 +278,65 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var sigs = body.GetProperty("signatures");
|
||||
Assert.Equal(JsonValueKind.Array, sigs.ValueKind);
|
||||
var first = sigs.EnumerateArray().First();
|
||||
// SignatureType.Pro = 0.
|
||||
Assert.Equal(0, first.GetProperty("type").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Array, first.GetProperty("strokes").ValueKind);
|
||||
|
||||
// Signatures ride on the estimate payload as two dedicated
|
||||
// slots, not a list: the provider signed here, so signaturePro
|
||||
// is populated and signatureClient is absent/null.
|
||||
var proSig = body.GetProperty("signaturePro");
|
||||
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();
|
||||
}
|
||||
|
||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -216,14 +216,16 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
|
||||
// GET: api/estimate/asprovider
|
||||
// Ongoing estimates (not yet validated by the client) established
|
||||
// by the current user as the provider.
|
||||
// Estimates awaiting the provider's signature, established by the
|
||||
// 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")]
|
||||
public IActionResult GetOngoingEstimatesAsProvider()
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
|
|
@ -8,6 +11,7 @@ using Yavsc.Models.Billing;
|
|||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.FrontOffice;
|
||||
using Yavsc.ViewModels.Gen;
|
||||
|
||||
namespace Yavsc.ApiControllers
|
||||
{
|
||||
|
|
@ -18,15 +22,36 @@ namespace Yavsc.ApiControllers
|
|||
ApplicationDbContext dbContext;
|
||||
private readonly IBillingService billing;
|
||||
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(
|
||||
ApplicationDbContext context,
|
||||
ILoggerFactory loggerFactory,
|
||||
IOptions<SiteSettings> siteSettings = null,
|
||||
IRazorViewEngine viewEngine = null,
|
||||
IBillingService billing = null)
|
||||
{
|
||||
dbContext = context;
|
||||
this.billing = billing ?? new BillingService(context);
|
||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||
this.siteSettings = siteSettings?.Value;
|
||||
this.viewEngine = viewEngine as IViewEngine;
|
||||
}
|
||||
|
||||
[HttpGet("profiles/{actCode}")]
|
||||
|
|
@ -35,6 +60,120 @@ namespace Yavsc.ApiControllers
|
|||
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
|
||||
//
|
||||
// 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" });
|
||||
|
||||
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))
|
||||
{
|
||||
// Admins bypass authorization but are neither party of
|
||||
|
|
@ -71,20 +219,37 @@ namespace Yavsc.ApiControllers
|
|||
if (estimate is null)
|
||||
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.Client;
|
||||
|
||||
var payload = new SignaturePadPayload
|
||||
{
|
||||
CoordinateMax = body!.CoordinateMax,
|
||||
CoordinateMax = body.CoordinateMax,
|
||||
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
||||
Strokes = body.Strokes!,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
signature = await EstimateSignaturePersister.StageAsync(
|
||||
dbContext, estimate.Id, type, uid, payload, token);
|
||||
}
|
||||
|
|
@ -94,7 +259,7 @@ namespace Yavsc.ApiControllers
|
|||
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
|
||||
}
|
||||
|
||||
if (role == ActorRole.Provider)
|
||||
if (actingRole == ActorRole.Provider)
|
||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||
else
|
||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||
|
|
@ -104,7 +269,7 @@ namespace Yavsc.ApiControllers
|
|||
// provider (ProAccepted) or the client (ClientAccepted).
|
||||
// An admin accepting (neither party) falls back to the
|
||||
// legacy generic Accepted.
|
||||
query.Status = role switch
|
||||
query.Status = actingRole switch
|
||||
{
|
||||
ActorRole.Provider => QueryStatus.ProAccepted,
|
||||
ActorRole.Client => QueryStatus.ClientAccepted,
|
||||
|
|
@ -222,5 +387,19 @@ namespace Yavsc.ViewModels.FrontOffice
|
|||
public int CoordinateMax { get; set; } = 10_000;
|
||||
|
||||
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.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
|
||||
namespace Yavsc.Helpers
|
||||
{
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using ViewModels.Gen;
|
||||
public class TeXString : HtmlString
|
||||
{
|
||||
|
|
@ -118,51 +118,65 @@ namespace Yavsc.Helpers
|
|||
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
||||
{
|
||||
string errorMsg = null;
|
||||
var billdir = Model.DestDir;
|
||||
var tempdir = settings.TempDir;
|
||||
// Resolve to an absolute path: SiteSettings.Bills may be a
|
||||
// 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 fullname = new FileInfo(
|
||||
System.IO.Path.Combine(tempdir, name)).FullName;
|
||||
string ofullname = new FileInfo(
|
||||
System.IO.Path.Combine(billdir, name)).FullName;
|
||||
FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf"));
|
||||
System.IO.Directory.CreateDirectory(billdir);
|
||||
|
||||
FileInfo fi = new FileInfo(fullname + ".tex");
|
||||
FileInfo fo = new FileInfo(ofullname + ".pdf");
|
||||
using (StreamWriter sw = new StreamWriter(fi.FullName))
|
||||
{
|
||||
sw.Write(Model.TeXSource);
|
||||
}
|
||||
if (!fi.Exists)
|
||||
{
|
||||
errorMsg = "Source write failed";
|
||||
}
|
||||
else
|
||||
// Compile the LaTeX source with the system lualatex, feeding it
|
||||
// the TeX on standard input (no intermediate .tex file) and
|
||||
// directing the resulting <name>.pdf straight into the bills
|
||||
// directory. lualatex is a file-based engine — it cannot emit
|
||||
// the PDF on stdout — so the pdf lands at its final destination
|
||||
// and we only clean the transient aux/log artifacts beside it.
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
using (Process p = new Process())
|
||||
{
|
||||
p.StartInfo.WorkingDirectory = tempdir;
|
||||
p.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = tempdir,
|
||||
FileName = "/usr/bin/texi2pdf",
|
||||
Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}"
|
||||
WorkingDirectory = billdir,
|
||||
FileName = "lualatex",
|
||||
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
||||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\"",
|
||||
RedirectStandardInput = true,
|
||||
};
|
||||
p.Start();
|
||||
using (var stdin = p.StandardInput)
|
||||
{
|
||||
stdin.Write(Model.TeXSource);
|
||||
}
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
|
||||
}
|
||||
else
|
||||
{
|
||||
fi.Delete();
|
||||
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
|
||||
di.Delete(true);
|
||||
|
||||
}
|
||||
exitCode = p.ExitCode;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMsg = "lualatex invocation failed: " + ex.Message;
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
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.GenerationErrorMessage = new HtmlString(errorMsg);
|
||||
return fo.Exists;
|
||||
|
|
@ -170,27 +184,41 @@ namespace Yavsc.Helpers
|
|||
|
||||
public static string RenderViewToString(
|
||||
this Controller controller, IViewEngine engine,
|
||||
IActionContextAccessor contextAccessor,
|
||||
string viewName, object model, bool isMainPage = true)
|
||||
string viewName, object model, bool isMainPage = true)
|
||||
{
|
||||
if (engine == null)
|
||||
throw new InvalidOperationException("no engine");
|
||||
|
||||
// Build an ActionContext from the controller's own context —
|
||||
// .NET 10 deprecated IActionContextAccessor, and the controller
|
||||
// already holds the live request context on its ControllerContext.
|
||||
var controllerContext = controller.ControllerContext;
|
||||
var actionContext = new ActionContext(
|
||||
controllerContext.HttpContext,
|
||||
controllerContext.RouteData,
|
||||
controllerContext.ActionDescriptor);
|
||||
|
||||
ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage);
|
||||
if (!viewResult.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"View '{viewName}' not found. Searched locations: "
|
||||
+ string.Join(", ", viewResult.SearchedLocations));
|
||||
|
||||
// Hand the model to the view through ViewData so the template's
|
||||
// @model directive resolves it (the previous implementation never
|
||||
// set ViewData.Model, leaving Model null inside the template).
|
||||
controller.ViewData.Model = model;
|
||||
|
||||
using (var sw = new StringWriter())
|
||||
{
|
||||
if (engine == null)
|
||||
throw new InvalidOperationException("no engine");
|
||||
|
||||
// try to find the specified view
|
||||
controller.TryValidateModel(model);
|
||||
ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage);
|
||||
|
||||
// create the associated context
|
||||
ViewContext viewContext = new ViewContext();
|
||||
viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor;
|
||||
viewContext.HttpContext = contextAccessor.ActionContext.HttpContext;
|
||||
viewContext.TempData = controller.TempData;
|
||||
viewContext.View = viewResult.View;
|
||||
viewContext.Writer = sw;
|
||||
// write the render view with the given context to the stringwriter
|
||||
viewResult.View.RenderAsync(viewContext).Wait();
|
||||
var viewContext = new ViewContext(
|
||||
actionContext,
|
||||
viewResult.View,
|
||||
controller.ViewData,
|
||||
controller.TempData,
|
||||
sw,
|
||||
new HtmlHelperOptions());
|
||||
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
||||
return sw.GetStringBuilder().ToString();
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,11 @@ internal class Program
|
|||
});
|
||||
})
|
||||
.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
|
||||
services.AddAuthentication("Bearer")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
@using System.Globalization
|
||||
@model IBillable
|
||||
@{
|
||||
Layout = "null";
|
||||
Layout = null;
|
||||
var pro = ViewBag.PerformerProfile;
|
||||
var from = ViewBag.Performer;
|
||||
var to = ViewBag.Client;
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
@using System.Globalization
|
||||
@model Estimate
|
||||
@{
|
||||
Layout = "null";
|
||||
Layout = null;
|
||||
var pro = Model.Query.PerformerProfile;
|
||||
var from = Model.Query.PerformerProfile.Performer;
|
||||
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 Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models;
|
||||
using ViewModels.FrontOffice;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -21,22 +20,20 @@ namespace Yavsc.Controllers
|
|||
readonly ILogger _logger;
|
||||
readonly IStringLocalizer _SR;
|
||||
private readonly IBillingService _billing;
|
||||
private readonly SiteSettings _siteSettings;
|
||||
|
||||
public FrontOfficeController(ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IBillingService billing,
|
||||
ILoggerFactory loggerFactory,
|
||||
IStringLocalizer<FrontOfficeController> SR,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
IStringLocalizer<FrontOfficeController> SR)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
||||
_SR = SR;
|
||||
_billing = billing;
|
||||
_siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
public ActionResult Index()
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
|
@ -80,61 +77,5 @@ namespace Yavsc.Controllers
|
|||
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(
|
||||
settings.Bills,
|
||||
AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId));
|
||||
AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId));
|
||||
|
||||
var fi = new FileInfo(item.FullName);
|
||||
if (fi.Exists) item.Overridden = true;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ namespace Yavsc.Models.Billing
|
|||
{
|
||||
using Models.Workflow;
|
||||
using Newtonsoft.Json;
|
||||
using System.Linq;
|
||||
|
||||
public partial class Estimate : IEstimate
|
||||
{
|
||||
|
|
@ -78,14 +79,39 @@ namespace Yavsc.Models.Billing
|
|||
public DateTime ClientValidationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All signatures captured against this estimate, in
|
||||
/// capture order. Multiple versions per (Type, SignerId)
|
||||
/// are allowed; the controller reads the most recent
|
||||
/// when asked. See <see cref="Signature"/>.
|
||||
/// All signatures captured against this estimate, keyed by
|
||||
/// <see cref="SignatureType"/>. The persister upserts by
|
||||
/// (EstimateId, Type), so there is at most one row per type.
|
||||
/// 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>
|
||||
[InverseProperty(nameof(Signature.Estimate))]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public virtual ICollection<Signature> Signatures { get; set; }
|
||||
= 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