diff --git a/CHANGELOG.md b/CHANGELOG.md index 85139db1b..3f8253c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,81 +1,5 @@ # Changelog -## [1.0.8-rc16] - unstable - -Flux de signature et de génération de devis : un devis peut désormais être -signé par les deux parties (fournisseur et client) puis téléchargé en PDF -ou TeX depuis PostIt, avec les signatures dessinées en vecteur. - -### Added - -* [PostIt] Pad de signature sur la page de validation d'un devis : on valide - ou on refuse en joignant une signature (côté fournisseur ou client), capturée - au format filaire PostIt (strokes) et envoyée sur l'acceptation. -* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` : - autorisation selon le rôle (fournisseur/client/admin), signature optionnelle - persistée sur l'estimate avec horodatage `ProviderValidationDate` / - `ClientValidationDate` selon le côté signataire. -* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted` - pour distinguer l'acceptation de chaque partie. -* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et - `estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est - rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`, - `BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs` - câble `AddControllersWithViews` + `SiteSettings`). -* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel - (`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client - côte à côte dans deux minipages ; plus de dépendance à un PNG de signature. -* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` / - `GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX » - dans PostIt sur la page de validation du devis et le détail de commande, - avec un helper `StorageProvider` d'enregistrement de fichier. -* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back), - de sorte qu'un devis validé quitte immédiatement la liste du signataire. -* [PostIt] Renommage des titres de liste : « Devis en attente de signature - fournisseur » / « Devis en attente de signature client ». -* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`. -* [CLI] Option `-c configuration-file.json` au lancement. -* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline - signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF - existe au chemin de sortie attendu et que seul le PDF persiste (aux/log - nettoyés) ; skip propre si `lualatex` est absent. -* [README] Dépendances système au runtime pour la génération PDF : - `texlive-binaries` (lualatex), `texlive-luatex` (luaotfload), - `texlive-pictures` (tikz), `texlive-fonts-extra` (bera). - -### Changed - -* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur - `ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste - « en attente ». -* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni - sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le - répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés - afin que seul le `.pdf` persiste. - -### Fixed - -* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument - fichier lit stdin en mode terminal et s'arrête après la première ligne d'un - document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier - d'entrée — stdin est alors traité comme un fichier régulier et le document - multi-lignes compile. C'était la cause racine de l'échec en production, pas - `bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets). -* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif - `Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de - `-output-directory` et de répertoire de travail ; résolu en chemin absolu. -* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model` - nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine` - résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine` - unique). -* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait - `null.cshtml` ; corrigé en `Layout = null` (null C#). -* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par - `lualatex`. -* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log` - qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message - d'erreur, et le `.log` est conservé en cas d'échec. - ## [1.0.8-rc15] - unstable ### Added @@ -445,8 +369,6 @@ the ACL now comes along with the article, the same user-visible switch without a schema change. [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD -[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 -[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/README.md b/README.md index fefbdd48d..67713fc64 100644 --- a/README.md +++ b/README.md @@ -61,37 +61,6 @@ make test ### les services et l'API -#### Dépendances système au runtime - -La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`, -`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande -système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit -directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc -être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service -systemd `yavscApi`. - -Sous Debian/Ubuntu : - -```bash -sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra -``` - -- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic). -- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex, - notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur - `module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF. - (Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers - `texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) -- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures - du client et du fournisseur en bas du devis (vecteur, pas de PNG). -- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis. - Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found` - (`Emergency stop`, aucun PDF). - -Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF -generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet -de compiler le document hors-ligne. - ### La Prod `cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 42d09cd55..05fe0e228 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -22,7 +22,6 @@ - @@ -31,4 +30,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 46702b3a8..a6d04cb33 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,8 +11,8 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index bf967849b..ff2f3f2b8 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,8 +6,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 12e9a8143..36ed1251e 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,8 +7,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 app.manifest diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index a2b8cd66f..0de3bd69e 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -12,8 +12,6 @@ sealed class Program [STAThread] public static void Main(string[] args) { - CLIOptionHandler.HandleArgs(args); - // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs index eefd59f20..6158b4132 100644 --- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -11,13 +11,12 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); - Assert.Equal("Devis en attente de signature client", vm.Title); + Assert.Equal("Mes devis à valider", vm.Title); Assert.Equal(2, vm.Estimates.Count); // Most recent first Assert.Equal(2, vm.Estimates[0].Id); @@ -29,13 +28,12 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); - Assert.Equal("Devis en attente de signature fournisseur", vm.Title); + Assert.Equal("Mes devis en attente", vm.Title); Assert.Equal(2, vm.Estimates.Count); } @@ -44,8 +42,7 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); await vm.InitializeAsync(); @@ -66,8 +63,7 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi { Empty = true }; var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); await vm.InitializeAsync(); diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs deleted file mode 100644 index 02a6c64bd..000000000 --- a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using PostIt.Controls; -using PostIt.ViewModels; -using Yavsc.Api.Client; -using Yavsc.Models.Billing; - -namespace PostIt.Tests; - -/// -/// Verifies wires the two -/// signature pads (provider + client) into the right -/// call. The view (XAML, rendering) -/// is not tested here; the VM is driven through the same -/// test hooks the signature tests use, -/// and the API client through a recording fake -/// . The test host's -/// TestApp makes GoBackAsync a no-op, so the success paths -/// run to completion without navigation side effects. -/// -public class EstimateValidationPageViewModelTests -{ - private static EstimateDto SampleEstimate(long? commandId = 42) => new() - { - Id = 7, - CommandId = commandId, - CommandType = "Rdv", - Title = "Devis plomberie", - Description = "Réparation", - ClientId = "bob", - OwnerId = "alice", - }; - - private static void DrawOneStroke(SignaturePadControl pad) - { - pad.AppendPointForTest(100, 200); - pad.AppendPointForTest(300, 400); - pad.SealStrokeForTest(); - } - - [Fact] - public async Task Validate_calls_accept_with_strokes_and_command_id() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - - var proPad = new SignaturePadControl(); - var clientPad = new SignaturePadControl(); - vm.Attach(proPad, clientPad); - DrawOneStroke(proPad); // provider signs on their writable pad - - await vm.ValidateAsync(); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Contains("front/query/accept", api.LastPath); - Assert.Contains("billingCode=Rdv", api.LastPath); - Assert.Contains("queryId=42", api.LastPath); - var body = Assert.IsType(api.LastBody); - 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(api.LastBody); - Assert.Equal(SignatureType.Client, body.SignatureType); // Client side - } - - [Fact] - public async Task Reject_calls_reject_endpoint_with_no_body() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - - vm.Attach(new SignaturePadControl(), new SignaturePadControl()); - - await vm.RejectAsync(); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Contains("front/query/reject", api.LastPath); - Assert.Contains("queryId=42", api.LastPath); - Assert.Null(api.LastBody); - } - - [Fact] - public async Task Validate_without_signature_warns_and_does_not_call() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - - vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads - - await vm.ValidateAsync(); // no throw: returns before GoBack - - Assert.Null(api.LastMethod); - Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Validate_command_gates_on_signature_and_command() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); - - var proPad = new SignaturePadControl(); - vm.Attach(proPad, new SignaturePadControl()); - - // No CommandId ⇒ cannot validate even after signing. - DrawOneStroke(proPad); - Assert.False(vm.ValidateCommand.CanExecute(null)); - - var vm2 = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - var proPad2 = new SignaturePadControl(); - vm2.Attach(proPad2, new SignaturePadControl()); - Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet - DrawOneStroke(proPad2); - Assert.True(vm2.ValidateCommand.CanExecute(null)); - } - - [Fact] - public void RoleLabel_reflects_perspective() - { - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - var pro = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - var cli = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - - Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase); - Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Only_the_author_pad_is_writable() - { - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - - 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 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_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.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 cliPad = new SignaturePadControl(); - 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 - { - public HttpClient Http { get; } = new(); - public HttpMethod? LastMethod { get; private set; } - public string? LastPath { get; private set; } - public object? LastBody { get; private set; } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.FromResult(default(T)!); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.CompletedTask; - } - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs deleted file mode 100644 index 02f6ae149..000000000 --- a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net.Http; -using Yavsc.Api.Client; - -namespace PostIt.Tests; - -/// -/// Verifies issues the right -/// api/v1/front/query/accept / .../reject requests with -/// the PostIt signature payload, using a recording fake -/// (no network). -/// -public class FrontOfficeApiClientTests -{ - private static QueryAcceptanceRequestDto SampleBody() => new() - { - Strokes = new[] { 2, 100, 200, 300, 400 }, - CoordinateMax = 10_000, - CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc), - }; - - [Fact] - public async Task Accept_posts_to_accept_endpoint_with_signature_body() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - var body = SampleBody(); - await client.AcceptQueryAsync("Rdv", 42, body); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42", - api.LastPath); - Assert.Same(body, api.LastBody); - } - - [Fact] - public async Task Reject_posts_to_reject_endpoint_with_no_body() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await client.RejectQueryAsync("Rdv", 42); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42", - api.LastPath); - Assert.Null(api.LastBody); - } - - [Fact] - public async Task Accept_returns_deserialized_response() - { - var api = new RecordingApi - { - Response = new QueryAcceptanceResponseDto - { - QueryId = 42, - Status = "Accepted", - Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" }, - }, - }; - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody()); - - Assert.Equal(42, response.QueryId); - Assert.Equal("Accepted", response.Status); - Assert.NotNull(response.Signature); - Assert.Equal("Pro", response.Signature!.Type); - } - - [Fact] - public async Task Accept_rejects_invalid_arguments() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("", 42, SampleBody())); - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 0, SampleBody())); - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 42, null!)); - await Assert.ThrowsAsync(() => client.RejectQueryAsync("", 42)); - await Assert.ThrowsAsync(() => 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(); - public HttpMethod? LastMethod { get; private set; } - public string? LastPath { get; private set; } - public object? LastBody { get; private set; } - public QueryAcceptanceResponseDto? Response { get; set; } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - - var response = Response ?? new QueryAcceptanceResponseDto(); - return Task.FromResult((T)(object)response); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.CompletedTask; - } - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = null; - return Task.FromResult(Array.Empty()); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index d4d16c29e..d2932665b 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -26,4 +26,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 9a04ffd03..b80351e17 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -17,7 +17,6 @@ namespace PostIt; public partial class App : Application { private int _bootStarted; - public string? CliConfigFileSpecification {  get; private set; } /// /// DI container the platform entry points hand to ViewModels so @@ -204,9 +203,4 @@ public partial class App : Application { await View!.NavRoot.PopAsync(); } - - internal void UseConfigFileWhenLoading(string configFile) - { - this.CliConfigFileSpecification = configFile; - } } diff --git a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs deleted file mode 100644 index c0bea85ff..000000000 --- a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using Mono.Options; -using PostIt; -using PostIt.ViewModels; - -public static class CLIOptionHandler -{ - public static void HandleArgs(string[] args) - { - bool showHelp = false; - string configFile = null; - - var options = new OptionSet { - { "c|configuration=", "Configuration file to load", (string v) => configFile = v }, - { "h|help", "Show this message and exit", v => showHelp = true } - }; - - List extra = options.Parse(args); - - if (showHelp) - { - Console.WriteLine("Usage: app [OPTIONS]+"); - options.WriteOptionDescriptions(Console.Out); - return; - } - if (configFile != null) - { - var app = App.Current as App; - app.UseConfigFileWhenLoading(configFile); - } - } -} diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs index bacb52486..dee8af36e 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadControl.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -46,21 +46,6 @@ public class SignaturePadControl : TemplatedControl set => SetValue(CaptureAreaProperty, value); } - /// - /// 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). - /// and still work. - /// - public static readonly StyledProperty IsReadOnlyProperty = - AvaloniaProperty.Register(nameof(IsReadOnly)); - - public bool IsReadOnly - { - get => GetValue(IsReadOnlyProperty); - set => SetValue(IsReadOnlyProperty, value); - } - /// /// Captured strokes in wire form. Exposed as a read-only view /// over the internal buffer. The buffer only mutates on the UI @@ -124,7 +109,6 @@ 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; @@ -197,38 +181,6 @@ public class SignaturePadControl : TemplatedControl RedrawRequested?.Invoke(this, EventArgs.Empty); } - /// - /// Replace the buffer with the given wire-format strokes — e.g. a - /// signature loaded back from the server — and raise - /// so the host view repaints the ink. - /// Loaded strokes are all sealed (there is no pending stroke). - /// Malformed input (a non-positive length prefix, or a stroke that - /// runs past the end of the array) is truncated at the offending - /// point so the buffer never holds an inconsistent state. - /// - public void Load(int[] strokes) - { - if (strokes is null) throw new ArgumentNullException(nameof(strokes)); - - _strokes.Clear(); - _pendingPoints = 0; - _capturing = false; - - int i = 0; - while (i < strokes.Length) - { - int k = strokes[i]; - if (k <= 0) break; - if (i + 1 + 2 * k > strokes.Length) break; - _strokes.Add(k); - for (int p = 0; p < 2 * k; p++) - _strokes.Add(strokes[i + 1 + p]); - i += 1 + 2 * k; - } - - RedrawRequested?.Invoke(this, EventArgs.Empty); - } - /// /// Defensive copy of the current buffer wrapped in a /// . Cheap; call only when the diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml b/src/PostIt/PostIt/Controls/SignaturePadView.axaml deleted file mode 100644 index cb0a99de1..000000000 --- a/src/PostIt/PostIt/Controls/SignaturePadView.axaml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs deleted file mode 100644 index ff75bf663..000000000 --- a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Shapes; -using Avalonia.Media; -using PostIt.Models; - -namespace PostIt.Controls; - -/// -/// Reusable signature-capture surface: wraps the render-agnostic -/// in a sized -/// (the hit-test region) with an overlay that -/// the code-behind repaints from the control's stroke buffer. Owns -/// the ink-rendering logic so every page that captures a signature -/// (the dev-only SignaturePage, the estimate validation page, -/// ...) shares one implementation. -/// -/// Hosts bind / -/// (DIPs, defaults 600×200) to size the surface. View models attach -/// to the inner control (exposed here) to -/// read snapshots, drive Clear, and subscribe to -/// / -/// — the same -/// contract the headless tests use, since a -/// needs its XAML loaded to be drivable. -/// -public partial class SignaturePadView : UserControl -{ - private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10)); - private const double StrokeThickness = 2.0; - private const double CoordinateMax = 10_000.0; - - // 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 CaptureWidthProperty = - AvaloniaProperty.Register(nameof(CaptureWidth), 600); - - public static readonly StyledProperty CaptureHeightProperty = - AvaloniaProperty.Register(nameof(CaptureHeight), 200); - - public double CaptureWidth - { - get => GetValue(CaptureWidthProperty); - set => SetValue(CaptureWidthProperty, value); - } - - public double CaptureHeight - { - get => GetValue(CaptureHeightProperty); - set => SetValue(CaptureHeightProperty, value); - } - - /// - /// The underlying capture control. View models attach to this to - /// read / call - /// and subscribe to its - /// events; this view repaints the ink layer from the same buffer. - /// - public SignaturePadControl SignaturePad => InnerPad; - - public SignaturePadView() - { - InitializeComponent(); - - // Wire the capture area: the Pad is the control, the - // surrounding Border (PadFrame) is the hit-test region. For an - // inline control with no template, capture wiring happens on - // first measure, which is guaranteed before the user can - // interact, so attaching here is safe. - InnerPad.CaptureArea = PadFrame; - ApplyFrameSize(); - - InnerPad.RedrawRequested += OnPadRedrawRequested; - // 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) - { - base.OnPropertyChanged(change); - if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty) - { - ApplyFrameSize(); - Repaint(); - } - } - - private void ApplyFrameSize() - { - if (PadFrame is null) return; - PadFrame.Width = Math.Max(1, CaptureWidth); - PadFrame.Height = Math.Max(1, CaptureHeight); - } - - private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint(); - - /// Defensive copy of the current buffer. - public SignaturePadData Snapshot() => InnerPad.Snapshot(); - - /// Forget every captured stroke. - public void Clear() => InnerPad.Clear(); - - /// Wire-format strokes, forwarded from the inner control. - public IReadOnlyList Strokes => InnerPad.Strokes; - - /// - /// The effective ink surface size: the arranged bounds when - /// available, otherwise the explicitly sized - /// /. The - /// latter covers a repaint triggered before the first layout pass - /// (e.g. loading an existing signature on reopen), when Bounds is - /// still empty. - /// - 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 = size.Width; - var h = size.Height; - - var pending = InnerPad.PendingStroke; - var strokes = InnerPad.Strokes; - int sealedCount = strokes.Count - pending.Count; - if (sealedCount < 0) - { - sealedCount = 0; - } - - int i = 0; - while (i < sealedCount) - { - int k = strokes[i]; - if (k <= 0) break; - i++; // skip the length prefix - - var poly = new Polyline - { - Stroke = StrokeBrush, - StrokeThickness = StrokeThickness, - StrokeLineCap = PenLineCap.Round, - StrokeJoin = PenLineJoin.Round, - }; - var pts = new List(k); - for (int p = 0; p < k; p++) - { - int nx = strokes[i++]; - int ny = strokes[i++]; - pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); - } - poly.Points = pts; - InkLayer.Children.Add(poly); - } - - if (pending.Count > 0) - { - var poly = new Polyline - { - Stroke = StrokeBrush, - StrokeThickness = StrokeThickness, - StrokeLineCap = PenLineCap.Round, - StrokeJoin = PenLineJoin.Round, - }; - - var pts = new List(pending.Count / 2); - for (int p = 0; p < pending.Count; p += 2) - { - int nx = pending[p]; - int ny = pending[p + 1]; - pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); - } - - poly.Points = pts; - InkLayer.Children.Add(poly); - } - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs b/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs deleted file mode 100644 index b99a33c87..000000000 --- a/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Platform.Storage; - -namespace PostIt.Helpers; - -/// -/// 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 -/// (, ). -/// -/// The TopLevel hosting the picker is resolved from the -/// application's root MainView (App.View), which works for -/// both the desktop and single-view lifetimes. -/// -public static class FileSaveHelpers -{ - /// - /// Prompt the user to choose a destination and write - /// to it. Returns false when the user - /// cancels the picker; throws on IO/platform errors so the caller - /// can surface them. - /// - public static async Task 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; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 2b243f2d5..bc5e7298f 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -31,7 +31,6 @@ public static class ServiceCollectionHelpers () => settings.Authentication?.Authority); var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); - var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); var reverseGeocoding = new NominatimReverseGeocodingService(); @@ -64,7 +63,6 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -76,7 +74,6 @@ public static class ServiceCollectionHelpers services.AddSingleton(activityClient); services.AddSingleton(billingClient); services.AddSingleton(estimateClient); - services.AddSingleton(frontClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 98dcbe480..3216cebb2 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -5,8 +5,8 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -36,7 +36,6 @@ - diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 555a7c675..04dbce1fd 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -478,41 +478,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable return "Avatar mis à jour."; } - /// - /// Download a binary resource (PDF, TeX, …) from the API. Unlike - /// , - /// which JSON-decodes the response, this returns the raw response - /// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once - /// contract as . - /// - public virtual async Task 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(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index ca9a7ff65..11797b714 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -55,7 +55,6 @@ public class ViewLocator : IDataTemplate ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), - EstimateValidationPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index dbd84e5c2..aab923d80 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -7,7 +7,6 @@ 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; @@ -131,13 +130,11 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV try { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); - var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( Activity, Performer, Form, _billingClient, - frontClient, details, IsReadOnly); await app.PushPageAsync(vm).ConfigureAwait(true); @@ -174,9 +171,5 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV } private static bool IsOngoingStatus(QueryStatus status) - => status is QueryStatus.Inserted - or QueryStatus.Accepted - or QueryStatus.ProAccepted - or QueryStatus.ClientAccepted - or QueryStatus.InProgress; + => status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; } diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index 9ab1b42fd..cd13daaf1 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -14,7 +14,6 @@ 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; } @@ -69,7 +68,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient, - FrontOfficeApiClient frontClient, BillingQueryDetailsDto details, bool isReadOnly) { @@ -77,7 +75,6 @@ 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; @@ -134,53 +131,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt await app.GoBackAsync().ConfigureAwait(true); } - /// - /// Download the estimate linked to this command as a LaTeX source - /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is - /// resolved server-side from Estimate.CommandId == query Id. - /// - [RelayCommand] - public async Task DownloadTexAsync() - { - await DownloadAsync( - () => _frontClient.GetEstimateTexAsync(_details.Id), - $"devis-{_details.Id}", "tex").ConfigureAwait(true); - } - - /// - /// Download the estimate linked to this command as a compiled PDF - /// (GET api/v1/front/query/{Id}/estimate.pdf). Requires - /// texi2pdf on the server host. - /// - [RelayCommand] - public async Task DownloadPdfAsync() - { - await DownloadAsync( - () => _frontClient.GetEstimatePdfAsync(_details.Id), - $"devis-{_details.Id}", "pdf").ConfigureAwait(true); - } - - private async Task DownloadAsync(Func> 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; @@ -216,8 +166,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#E6F7EC", - QueryStatus.ProAccepted => "#E6F7EC", - QueryStatus.ClientAccepted => "#E1F5FE", QueryStatus.InProgress => "#FFF4D6", QueryStatus.Rejected => "#FDECEA", QueryStatus.Failed => "#ECEFF1", @@ -229,8 +177,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#2E7D32", - QueryStatus.ProAccepted => "#2E7D32", - QueryStatus.ClientAccepted => "#1565C0", QueryStatus.InProgress => "#B26A00", QueryStatus.Rejected => "#C62828", QueryStatus.Failed => "#607D8B", @@ -242,8 +188,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#1B5E20", - QueryStatus.ProAccepted => "#1B5E20", - QueryStatus.ClientAccepted => "#0D47A1", QueryStatus.InProgress => "#7A4A00", QueryStatus.Rejected => "#8E0000", QueryStatus.Failed => "#37474F", @@ -255,8 +199,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "OK", - QueryStatus.ProAccepted => "P", - QueryStatus.ClientAccepted => "C", QueryStatus.InProgress => "~", QueryStatus.Rejected => "!", QueryStatus.Failed => "X", diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs index c90a6d884..e5c4d355f 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Helpers; @@ -19,10 +18,10 @@ namespace PostIt.ViewModels; /// public enum EstimateListPerspective { - /// « Devis en attente de signature client » — l'utilisateur courant est le client. + /// « Mes devis à valider » — l'utilisateur courant est le client. Client, - /// « Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur. + /// « Mes devis en attente » — l'utilisateur courant est le fournisseur. Provider, } @@ -33,15 +32,14 @@ public enum EstimateListPerspective public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateApiClient _estimateClient; - private readonly FrontOfficeApiClient _frontClient; private readonly EstimateListPerspective _perspective; private List _allEstimates = new(); public EstimateListPerspective Perspective => _perspective; public string Title => _perspective == EstimateListPerspective.Client - ? "Devis en attente de signature client" - : "Devis en attente de signature fournisseur"; + ? "Mes devis à valider" + : "Mes devis en attente"; public string Subtitle => _perspective == EstimateListPerspective.Client ? "Point de vue client" @@ -85,14 +83,10 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie protected set { _ = value; } } - public EstimateListPageViewModel( - EstimateApiClient estimateClient, - EstimateListPerspective perspective, - FrontOfficeApiClient frontClient) + public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) { _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _perspective = perspective; - _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); } public Task InitializeAsync() => RefreshAsync(); @@ -159,25 +153,4 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie private static bool ContainsInsensitive(string? value, string query) => !string.IsNullOrWhiteSpace(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase); - - /// - /// Open the estimate validation (accept/reject with signature) - /// page for a specific estimate. The perspective the list was - /// opened in is forwarded as the actor-role UI hint; the server - /// independently enforces the real authorization. - /// - [RelayCommand] - private async Task OpenEstimateAsync(EstimateDto? estimate) - { - if (estimate is null) return; - - var app = (App?)Application.Current; - if (app is null) - { - throw new InvalidOperationException("Application PostIt indisponible."); - } - - var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient); - await app.PushPageAsync(vm).ConfigureAwait(true); - } } diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs deleted file mode 100644 index 87347c2ce..000000000 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs +++ /dev/null @@ -1,399 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using Avalonia; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using PostIt.Controls; -using PostIt.Helpers; -using PostIt.Models; -using Yavsc.Api.Client; -using Yavsc.Models.Billing; - -namespace PostIt.ViewModels; - -/// -/// Validation (accept / reject) of a single estimate, with optional -/// signatures. Backs the estimate validation page opened from -/// for either perspective. -/// -/// 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 -/// is writable — the server -/// (POST api/v1/front/query/accept) independently enforces the -/// same rule (it resolves the caller's role from the authenticated user -/// vs the query's PerformerId/ClientId), and the UI marks -/// the other pad read-only. The billing code sent to the server is the -/// estimate's and the query id is -/// its . -/// -public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel -{ - private readonly EstimateDto _estimate; - private readonly EstimateListPerspective _perspective; - private readonly FrontOfficeApiClient _frontClient; - private SignaturePadControl? _proPad; - private SignaturePadControl? _clientPad; - - public string Title => _estimate.Title; - public string Description => _estimate.Description; - public long EstimateId => _estimate.Id; - public string CommandType => _estimate.CommandType; - public IReadOnlyList Bill => _estimate.Bill; - public DateTime ProviderValidationDate => _estimate.ProviderValidationDate; - public DateTime ClientValidationDate => _estimate.ClientValidationDate; - - public string RoleLabel => _perspective == EstimateListPerspective.Provider - ? "Vous validez en tant que prestataire" - : "Vous validez en tant que client"; - - /// - /// 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. - /// - public bool CanWritePro => _perspective == EstimateListPerspective.Provider; - - /// - /// True when the current user may sign the client's signature - /// (i.e. they opened the list as the client). - /// - public bool CanWriteClient => _perspective == EstimateListPerspective.Client; - - /// - /// Human-readable summary of who has already validated this - /// estimate, shown above the signature pads. - /// - public string ValidationStateText - { - get - { - var parts = new List(); - if (ProviderValidationDate != default) - parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}"); - if (ClientValidationDate != default) - parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}"); - return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts); - } - } - - /// - /// True when the estimate has a linked query to accept/reject. - /// Estimates without a are - /// templates and cannot be validated through the front-office - /// endpoint. - /// - public bool HasCommand => _estimate.CommandId is not null; - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = "Prêt."; - - [ObservableProperty] - public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); - - /// Stroke/point counts for the writable pad. - [ObservableProperty] - public partial int StrokeCount { get; set; } - - [ObservableProperty] - public partial int PointCount { get; set; } - - /// True when the writable pad holds a signature. - [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; - protected set { _ = value; } - } - - public override bool CanNavigatePrevious - { - get => true; - protected set { _ = value; } - } - - public EstimateValidationPageViewModel( - EstimateDto estimate, - EstimateListPerspective perspective, - FrontOfficeApiClient frontClient) - { - _estimate = estimate ?? throw new ArgumentNullException(nameof(estimate)); - _perspective = perspective; - _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); - } - - /// - /// Bind the page's two signature pads (the inner controls of the - /// page's 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. - /// - public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad) - { - if (proPad is null) throw new ArgumentNullException(nameof(proPad)); - if (clientPad is null) throw new ArgumentNullException(nameof(clientPad)); - Detach(); - _proPad = proPad; - _clientPad = clientPad; - _proPad.IsReadOnly = !CanWritePro; - _clientPad.IsReadOnly = !CanWriteClient; - _proPad.RedrawRequested += OnPadRedraw; - _clientPad.RedrawRequested += OnPadRedraw; - LoadExistingSignatures(); - RefreshFromPad(); - } - - public void Detach() - { - 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(); - - /// The pad the current user may sign in. - private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad; - - /// - /// Render the signatures already stored for this estimate back into - /// their pads. The strokes are carried on the estimate payload as - /// two dedicated slots — and - /// — so each pad loads - /// its own side directly, in the same wire format PostIt captured. - /// No network round-trip. - /// - public void LoadExistingSignatures() - { - 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() - { - 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; - } - - partial void OnIsBusyChanged(bool value) - { - ValidateCommand.NotifyCanExecuteChanged(); - RejectCommand.NotifyCanExecuteChanged(); - } - - partial void OnHasSignatureChanged(bool value) - => ValidateCommand.NotifyCanExecuteChanged(); - - private bool CanValidate => !IsBusy && HasSignature && HasCommand; - - private bool CanReject => !IsBusy && HasCommand; - - [RelayCommand(CanExecute = nameof(CanValidate))] - public async Task ValidateAsync() - { - if (_estimate.CommandId is not long queryId) - { - this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé."); - return; - } - var pad = WritablePad; - if (pad is null || pad.Snapshot().IsEmpty) - { - this.SetWarningStatus("Veuillez signer avant de valider."); - return; - } - - var snap = pad.Snapshot(); - IsBusy = true; - try - { - var body = new QueryAcceptanceRequestDto - { - Strokes = snap.Strokes, - CoordinateMax = SignaturePadData.CoordinateMax, - CapturedAtUtc = DateTime.UtcNow, - // 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) - .ConfigureAwait(true); - - this.SetInfoStatus("Devis validé."); - await GoBackAsync().ConfigureAwait(true); - } - catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) - { - this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); - } - catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest) - { - this.SetWarningStatus($"Validation refusée: {ex.Message}"); - } - catch (Exception ex) - { - this.SetErrorStatus($"Erreur: {ex.Message}"); - } - finally - { - IsBusy = false; - } - } - - [RelayCommand(CanExecute = nameof(CanReject))] - public async Task RejectAsync() - { - if (_estimate.CommandId is not long queryId) - { - this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé."); - return; - } - - IsBusy = true; - try - { - await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId) - .ConfigureAwait(true); - - this.SetInfoStatus("Devis refusé."); - await GoBackAsync().ConfigureAwait(true); - } - catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) - { - this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); - } - catch (Exception ex) - { - this.SetErrorStatus($"Erreur: {ex.Message}"); - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public void ClearSignature() - { - WritablePad?.Clear(); - RefreshFromPad(); - } - - /// - /// Download the estimate as a LaTeX source document. The server - /// renders the Estimate_tex template to a .tex byte stream - /// (GET api/v1/front/query/{CommandId}/estimate.tex); the - /// user picks a destination via the platform save picker. - /// - [RelayCommand] - public async Task DownloadTexAsync() - { - await DownloadAsync( - () => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value), - $"devis-{EstimateId}", "tex").ConfigureAwait(true); - } - - /// - /// Download the estimate as a compiled PDF - /// (GET api/v1/front/query/{CommandId}/estimate.pdf). - /// Requires texi2pdf on the server host; a failure is - /// surfaced as a status message. - /// - [RelayCommand] - public async Task DownloadPdfAsync() - { - await DownloadAsync( - () => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value), - $"devis-{EstimateId}", "pdf").ConfigureAwait(true); - } - - private async Task DownloadAsync(Func> 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); - - private static async Task GoBackAsync() - { - var app = (App?)Application.Current; - if (app is null) - { - throw new InvalidOperationException("Application PostIt indisponible."); - } - await app.GoBackAsync().ConfigureAwait(true); - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 75a5bdc9a..71e979e66 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -8,7 +8,6 @@ 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; @@ -154,14 +153,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); - var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( activity, performer, form, _billingClient, - frontClient, details, isReadOnly: false); @@ -347,13 +344,11 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi => status switch { QueryStatus.InProgress => 0, - QueryStatus.ClientAccepted => 1, - QueryStatus.ProAccepted => 2, - QueryStatus.Accepted => 2, - QueryStatus.Inserted => 3, - QueryStatus.Success => 4, - QueryStatus.Rejected => 5, - QueryStatus.Failed => 6, + QueryStatus.Accepted => 1, + QueryStatus.Inserted => 2, + QueryStatus.Success => 3, + QueryStatus.Rejected => 4, + QueryStatus.Failed => 5, _ => 99, }; diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 9d466b26c..ea92f1d0a 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -90,13 +90,7 @@ public class HomePageViewModel : ViewModelBase throw new InvalidOperationException("Client devis indisponible."); } - var frontClient = app.ServiceProvider?.GetRequiredService(); - if (frontClient is null) - { - throw new InvalidOperationException("Client front-office indisponible."); - } - - var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient); + var vm = new EstimateListPageViewModel(estimateClient, perspective); await vm.InitializeAsync(); await app.PushPageAsync(vm); } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 5576f01cd..1c2802e33 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -259,13 +259,25 @@ public partial class Settings : ViewModelBase return; } } - - var app = App.Current as App; - if (app!=null) - if (app.CliConfigFileSpecification!=null) + if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson + && !string.IsNullOrWhiteSpace(envJson)) { - SettingsFileFullName = app.CliConfigFileSpecification; + Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable."); + FileInfo configByEnvFileInfo = new FileInfo(envJson); + if (!configByEnvFileInfo.Exists) + { + throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}"); + } + string json = File.ReadAllText(configByEnvFileInfo.FullName); + ApplyJson(json, "POSTIT_SETTINGS_JSON"); + SettingsFileFullName = configByEnvFileInfo.FullName; + Loaded = true; + return; } + string configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PostIt" + ); if (SettingsFileFullName is not null) { @@ -275,18 +287,10 @@ public partial class Settings : ViewModelBase else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath && !string.IsNullOrWhiteSpace(envPath)) { - FileInfo configByEnvFileInfo = new FileInfo(envPath); - if (!configByEnvFileInfo.Exists) - { - throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}"); - } - SettingsFileFullName = configByEnvFileInfo.FullName; + SettingsFileFullName = envPath; } else { - string configDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "PostIt"); SettingsFileFullName = Path.Combine(configDir, "postit-settings.json"); } @@ -532,7 +536,6 @@ public partial class Settings : ViewModelBase /// partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); - public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } } diff --git a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs index 1e7df9c60..23ba70cf9 100644 --- a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs @@ -85,12 +85,10 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo } /// - /// Bind the inner of the page's - /// to this VM. Called from the - /// page's code-behind once the view is in the visual tree (so the - /// control's is - /// wired). Attaching to the control directly (rather than the - /// view) keeps the same contract the headless tests use. + /// Bind a freshly-constructed (or re-templated) control to this + /// VM. Called from the view's code-behind once the control has + /// been added to the visual tree and its template applied (so + /// is wired). /// public void Attach(SignaturePadControl control) { diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 904f17350..82231ad83 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -14,7 +14,7 @@ - + public enum QueryStatus: int { - Inserted = 0, - Rejected = 1, - Accepted = 2, - InProgress = 3, + Inserted, + Rejected, + Accepted, + InProgress, // final states - Failed = 4, - Success = 5, - // party-specific acceptance (see summary) - ProAccepted = 6, - ClientAccepted = 7, + Failed, + Success } -} \ No newline at end of file +} diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index 0a9e481bf..4df6c53c2 100644 --- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj +++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj @@ -11,7 +11,7 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 \ No newline at end of file diff --git a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs index 3e7a15052..43c09d94a 100644 --- a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs +++ b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs @@ -39,39 +39,6 @@ public sealed class EstimateDto public string CommandType { get; set; } = string.Empty; public DateTime ProviderValidationDate { get; set; } public DateTime ClientValidationDate { get; set; } - - /// - /// The provider's signature on this estimate, or null when the - /// provider has not signed. Carries the PostIt wire-format - /// so the pad can - /// repaint a previously drawn signature on reopen without a second - /// round-trip. - /// - public EstimateSignatureDto? SignaturePro { get; set; } - - /// - /// The client's signature on this estimate, or null when the - /// client has not signed. - /// - public EstimateSignatureDto? SignatureClient { get; set; } -} - -/// -/// One stored signature on an estimate, mirroring the JSON shape of -/// the server-side Yavsc.Models.Billing.Signature entity. The -/// are the same PostIt wire-format payload that -/// was captured and are loaded back into the pad unchanged. The side -/// (Pro/Client) is implicit in which property of -/// carries this DTO, so no Type -/// field is needed. -/// -public sealed class EstimateSignatureDto -{ - public long Id { get; set; } - public long EstimateId { get; set; } - public int CoordinateMax { get; set; } - public DateTime CapturedAtUtc { get; set; } - public int[]? Strokes { get; set; } } /// diff --git a/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs b/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs deleted file mode 100644 index dffbbf3ad..000000000 --- a/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using Yavsc.Models.Billing; - -namespace Yavsc.Api.Client; - -/// -/// Body of POST api/v1/front/query/accept and -/// .../query/reject, mirroring the server-side -/// Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest. -/// is optional: when non-empty on accept it is -/// the PostIt wire-format signature captured with the acceptance; null -/// or empty means no signature (the only valid value on reject). -/// -public sealed class QueryAcceptanceRequestDto -{ - public int[]? Strokes { get; set; } - - public int CoordinateMax { get; set; } = 10_000; - - public DateTime? CapturedAtUtc { get; set; } - - /// - /// Which signature is being submitted: - /// (the provider) or - /// . 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. - /// - public SignatureType? SignatureType { get; set; } -} - -/// -/// Response of POST .../query/accept / .../query/reject -/// (Ok(new { queryId, status, signature = ... })). The -/// ref is non-null only when a signature was -/// staged on accept. -/// -public sealed class QueryAcceptanceResponseDto -{ - public long QueryId { get; set; } - - public string? Status { get; set; } - - public QuerySignatureRefDto? Signature { get; set; } -} - -public sealed class QuerySignatureRefDto -{ - public long Id { get; set; } - - public long EstimateId { get; set; } - - public string? Type { get; set; } -} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/FrontOfficeApiClient.cs b/src/Yavsc.Api.Client/FrontOfficeApiClient.cs deleted file mode 100644 index 84c7b8a0c..000000000 --- a/src/Yavsc.Api.Client/FrontOfficeApiClient.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Yavsc.Api.Client; - -/// -/// HTTP client for the front-office query routes -/// (api/v1/front/query/accept / .../reject, served by -/// ). Same -/// DTO↔path mapper shape as : transport -/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated -/// to . -/// -/// The server resolves the caller's role (provider vs client vs admin) -/// from the authenticated user compared to the query's -/// PerformerId/ClientId; the client does not declare a -/// role. is the estimate's -/// and -/// is its . -/// -public sealed class FrontOfficeApiClient -{ - private const string PathPrefix = "front"; - - private readonly IYavscApiClient _api; - private readonly Func _businessBaseAddress; - - public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress) - : this(api, () => businessBaseAddress) - { - } - - public FrontOfficeApiClient(IYavscApiClient api, Func businessBaseAddress) - { - _api = api ?? throw new ArgumentNullException(nameof(api)); - _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress)); - _ = ResolveBusinessBaseAddress(); - } - - /// - /// Accept (validate) the query under - /// billing code . When - /// carries a non-empty signature, the - /// server stages it on the query's linked estimate and stamps the - /// matching validation date. - /// - public async Task AcceptQueryAsync( - string billingCode, - long queryId, - QueryAcceptanceRequestDto body, - CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(billingCode)) - throw new ArgumentException("billingCode is required.", nameof(billingCode)); - if (queryId <= 0) - throw new ArgumentOutOfRangeException(nameof(queryId)); - if (body is null) - throw new ArgumentNullException(nameof(body)); - - var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}"; - var response = await _api.CallAsync( - HttpMethod.Post, - Absolute(path), - body: body, - ct: ct).ConfigureAwait(false); - - return response ?? new QueryAcceptanceResponseDto { QueryId = queryId }; - } - - /// - /// Reject the query under billing code - /// . A signature is not meaningful on - /// a rejection; the server refuses one with 400, so this sends no - /// body. - /// - public async Task RejectQueryAsync( - string billingCode, - long queryId, - CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(billingCode)) - throw new ArgumentException("billingCode is required.", nameof(billingCode)); - if (queryId <= 0) - throw new ArgumentOutOfRangeException(nameof(queryId)); - - var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}"; - var response = await _api.CallAsync( - HttpMethod.Post, - Absolute(path), - body: null, - ct: ct).ConfigureAwait(false); - - return response ?? new QueryAcceptanceResponseDto { QueryId = queryId }; - } - - /// - /// Download the estimate linked to query - /// as a LaTeX (.tex) source document - /// (GET api/v1/front/query/{queryId}/estimate.tex). Returns - /// the raw TeX bytes (UTF-8 text). - /// - public Task 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); - } - - /// - /// Download the estimate linked to query - /// as a compiled PDF document - /// (GET api/v1/front/query/{queryId}/estimate.pdf). Returns - /// the raw PDF bytes. Requires texi2pdf on the server host; - /// a non-2xx response throws . - /// - public Task 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() - { - var raw = _businessBaseAddress(); - if (string.IsNullOrWhiteSpace(raw)) - throw new InvalidOperationException("Business base address is required."); - - return new Uri(raw, UriKind.Absolute); - } -} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs index e2dd56acd..1d470b682 100644 --- a/src/Yavsc.Api.Client/IYavscApiClient.cs +++ b/src/Yavsc.Api.Client/IYavscApiClient.cs @@ -73,23 +73,4 @@ public interface IYavscApiClient : IAsyncDisposable string path, Func contentFactory, CancellationToken ct = default); - - /// - /// Download a binary resource (PDF, TeX, image, …). Unlike - /// , - /// which JSON-decodes the response, this returns the raw response - /// bytes. The same Bearer-token attach + 401-refresh-retry-once - /// contract applies. - /// - /// - /// A default interface implementation is provided so that test - /// fakes that do not exercise binary download still compile without - /// declaring the member; the real - /// overrides it with the authenticated transport. - /// - Task DownloadAsync( - HttpMethod method, - string path, - CancellationToken ct = default) - => Task.FromResult(Array.Empty()); } diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index c53f9e888..f0cdb55c9 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -17,8 +17,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs index 568175610..d8cae0265 100644 --- a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs +++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs @@ -284,14 +284,11 @@ public sealed class EstimateApiControllerTests : IClassFixture.pdf into DestDir. The -// TeX source mirrors what the Estimate_tex template emits for a devis -// signed by both parties: two side-by-side minipages, each with a -// tikzpicture bounded by \useasboundingbox and the strokes drawn by -// SignatureToTikz. -// -// The PDF test requires the system `lualatex` (and the TeX Live packages -// listed in the README runtime-deps section) on the host running the -// tests. On hosts without lualatex it skips cleanly rather than failing. -public sealed class EstimatePdfGenerationTests -{ - // A provider signature: a downstroke, a cross, and a tap (dot). - // PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in - // [0, 10000], y-down. - private static readonly int[] ProStrokes = - { - 4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900, - 3, 2000, 5000, 3500, 4500, 3500, 5100, - 1, 4000, 3000, - }; - - // A client signature: a wavy stroke and a shorter one. - private static readonly int[] CliStrokes = - { - 5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500, - 2, 3000, 6000, 4200, 5500, - }; - - [Fact] - public void SignatureToTikz_RendersBothSignaturesAsVectorDraws() - { - var pro = MakeSig(SignatureType.Pro, ProStrokes); - var cli = MakeSig(SignatureType.Client, CliStrokes); - - var proTex = pro.SignatureToTikz(5, 2.5).ToString(); - var cliTex = cli.SignatureToTikz(5, 2.5).ToString(); - - Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex); - Assert.Contains("\\fill", proTex); // the single-point tap → dot - Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped - Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex); - Assert.Contains("--", cliTex); - } - - [Fact] - public void SignatureToTikz_EmptySignatureIsEmpty() - { - var empty = MakeSig(SignatureType.Pro, Array.Empty()); - Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString()); - } - - [Fact] - public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir() - { - if (!IsLualatexAvailable()) - throw Xunit.Sdk.SkipException.ForSkip( - "lualatex is not installed on this host; skipping PDF generation test. " + - "Install the TeX Live runtime deps documented in the README."); - - var destDir = Path.Combine(Path.GetTempPath(), - "yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(destDir); - - try - { - var pro = MakeSig(SignatureType.Pro, ProStrokes); - var cli = MakeSig(SignatureType.Client, CliStrokes); - var baseName = "estimate-test-both-signed"; - - var model = new PdfGenerationViewModel - { - TeXSource = BuildEstimateTeX(pro, cli), - BaseFileName = baseName, - DestDir = destDir, - }; - - var fo = model.GenerateEstimatePdf(); - - // The PDF must exist, at the expected path, and be non-empty. - var expectedPath = Path.Combine(destDir, baseName + ".pdf"); - Assert.True(fo is not null && fo.Exists, - "GenerateEstimatePdf returned a non-existent file. Error: " - + model.GenerationErrorMessage?.Value); - Assert.Equal(expectedPath, fo.FullName); - Assert.True(fo.Length > 0, "generated PDF is empty"); - - // Only the PDF should persist in DestDir — aux/log artifacts - // are cleaned up by GenerateEstimatePdf on success. - var leftovers = Directory.GetFiles(destDir) - .Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal)) - .ToArray(); - Assert.Empty(leftovers); - } - finally - { - if (Directory.Exists(destDir)) - Directory.Delete(destDir, recursive: true); - } - } - - private static Signature MakeSig(SignatureType type, int[] strokes) - => new() - { - Type = type, - CoordinateMax = 10_000, - Strokes = strokes, - CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), - }; - - // Builds the LaTeX source the Estimate_tex template would emit for a - // devis signed by both parties: same preamble (incl. tikz + bera) and - // the two-minipage signature foot, with the strokes drawn by - // SignatureToTikz. - private static string BuildEstimateTeX(Signature pro, Signature cli) - { - var fr = CultureInfo.CreateSpecificCulture("fr-FR"); - var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr); - var proTikz = pro.SignatureToTikz(5, 2.5).ToString(); - var cliTikz = cli.SignatureToTikz(5, 2.5).ToString(); - - return $@"\documentclass[french,11pt]{{article}} -\usepackage{{eurosym}} -\usepackage{{babel}} -\usepackage[T1]{{fontenc}} -\usepackage[utf8]{{inputenc}} -\usepackage[a4paper]{{geometry}} -\usepackage{{units}} -\usepackage{{bera}} -\usepackage{{graphicx}} -\usepackage{{fancyhdr}} -\usepackage{{fp}} -\usepackage{{tikz}} -\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}} -\setlength{{\parindent}}{{0pt}} -\begin{{document}} -Devis n°1 --- test signe par les deux parties - -\vspace{{2em}} -\noindent -\begin{{minipage}}[t]{{0.45\textwidth}} - \textbf{{Signature du fournisseur}}\\ -\small Valide le {date}\\ -\begin{{tikzpicture}} - \useasboundingbox (0,0) rectangle (5,2.5); - {proTikz} -\end{{tikzpicture}} -\end{{minipage}}\hfill -\begin{{minipage}}[t]{{0.45\textwidth}} - \textbf{{Signature du client}}\\ -\small Valide le {date}\\ -\begin{{tikzpicture}} - \useasboundingbox (0,0) rectangle (5,2.5); - {cliTikz} -\end{{tikzpicture}} -\end{{minipage}} -\end{{document}} -"; - } - - private static bool IsLualatexAvailable() - { - try - { - using var p = Process.Start(new ProcessStartInfo("lualatex", "--version") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }); - if (p is null) return false; - return p.WaitForExit(5000) && p.ExitCode == 0; - } - catch - { - return false; - } - } -} \ No newline at end of file diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs index b2a8fdbee..55e830660 100644 --- a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs +++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs @@ -1,8 +1,6 @@ -using System.Linq; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Api.Test.Fixtures; using Yavsc.Helpers; @@ -45,26 +43,19 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture JsonBody(new { }); - 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 HttpContent WithSignature() => + JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 }); private static JsonContent JsonBody(object value) => JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json")); /// - /// Seeds an Rdv query and, when is - /// true, an estimate linked to it via CommandId. The provider is - /// (default alice) and the client is - /// (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. + /// Seeds an Rdv query with distinct parties (provider alice, + /// client bob) and, when is true, + /// an estimate linked to it via CommandId. Returns the query id + /// and (when seeded) the estimate id. /// - private (long queryId, long estimateId) SeedQuery( - bool withEstimate, - string performer = "alice", - string client = "bob") + private (long queryId, long estimateId) SeedQuery(bool withEstimate) { WorkflowHelpers.ConfigureBillingService(); _fixture.ResetAndSeedActivityGraph(); @@ -77,11 +68,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); var updated = assertDb.RdvQueries.Single(q => q.Id == queryId); - // Default caller is "alice" (the provider) → ProAccepted. - Assert.Equal(QueryStatus.ProAccepted, updated.Status); + Assert.Equal(QueryStatus.Accepted, updated.Status); } [Fact] @@ -156,7 +146,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); - Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); + Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); Assert.True(estimate.ProviderValidationDate != default); @@ -183,7 +173,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); - Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); + Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); Assert.True(estimate.ClientValidationDate != default); @@ -253,90 +243,4 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); - - // 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(); - - 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); - } } \ No newline at end of file diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj index 21cbdc32c..895b991fa 100644 --- a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj +++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -30,4 +30,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index aa195712d..ba43094a9 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -157,8 +157,6 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.ProAccepted - || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -171,8 +169,6 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.ProAccepted - || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -185,8 +181,6 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted - || q.Status == QueryStatus.ProAccepted - || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -335,9 +329,9 @@ namespace Yavsc.ApiControllers return new ChallengeResult(); } - var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id); + var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id); FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename)); - if (!fi.Exists) return NotFound(new { Error = "Client signature not found" }); + if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" }); return File(fi.OpenRead(), "application/x-pdf", filename); } diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs index c1e25cc27..1ee88f953 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs @@ -40,7 +40,7 @@ namespace Yavsc.Controllers else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ; // or just do nothing return new StatusCodeResult(StatusCodes.Status403Forbidden); - return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId)); + return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId)); } // GET: api/Estimate/5 [HttpGet("{id}", Name = "GetEstimate")] @@ -51,7 +51,7 @@ namespace Yavsc.Controllers return BadRequest(ModelState); } - Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id); + Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id); if (estimate == null) { @@ -209,23 +209,21 @@ namespace Yavsc.Controllers public IActionResult GetOngoingEstimatesAsClient() { var uid = User.GetUserId(); - return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) + return Ok(_context.Estimates.Include(e => e.Bill) .Where(e => e.ClientId == uid && e.ProviderValidationDate != default && e.ClientValidationDate == default)); } // GET: api/estimate/asprovider - // 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. + // Ongoing estimates (not yet validated by the client) established + // by the current user as the provider. [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.ProviderValidationDate == default)); + return Ok(_context.Estimates.Include(e => e.Bill) + .Where(e => e.OwnerId == uid && e.ClientValidationDate == default)); } protected override void Dispose(bool disposing) diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index eb7995f5e..5f328728c 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -1,9 +1,6 @@ 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; @@ -11,7 +8,6 @@ using Yavsc.Models.Billing; using Yavsc.Services; using Yavsc.Server.Helpers; using Yavsc.ViewModels.FrontOffice; -using Yavsc.ViewModels.Gen; namespace Yavsc.ApiControllers { @@ -22,36 +18,15 @@ namespace Yavsc.ApiControllers ApplicationDbContext dbContext; private readonly IBillingService billing; private readonly ILogger logger; - private readonly SiteSettings siteSettings; - private readonly IRazorViewEngine 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 = null, - IRazorViewEngine viewEngine = null, IBillingService billing = null) { dbContext = context; this.billing = billing ?? new BillingService(context); logger = loggerFactory.CreateLogger(); - this.siteSettings = siteSettings?.Value; - this.viewEngine = viewEngine; } [HttpGet("profiles/{actCode}")] @@ -60,126 +35,6 @@ 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 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 EstimatePdf(long queryId, CancellationToken token) - { - if (viewEngine is null || siteSettings is null) - { - string msg = "PDF rendering is not configured on this host."; - logger.LogError(msg); - return Problem(msg, - 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. - if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists) - { - return File(fo.OpenRead(), "application/pdf"); - } - string msg = "TeX render failed: " + model.GenerationErrorMessage; - logger.LogError(msg); - return Problem(msg); - - } - catch (Exception ex) - { - logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId); - return Problem("TeX render failed: " + ex.Message); - } - } - - // Loads the estimate linked to a query with the navigation the - // Estimate_tex template reads (client + performer profile + bill - // + the captured signatures, drawn as tikz at the foot of the devis). - private Task LoadEstimateForRenderAsync(long queryId, CancellationToken token) - => dbContext.Estimates - .Where(e => e.CommandId == queryId) - .OrderByDescending(e => e.Id) - .Include(e => e.Bill) - .Include(e => e.Signatures) - .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 @@ -202,15 +57,6 @@ 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 @@ -225,37 +71,20 @@ namespace Yavsc.ApiControllers if (estimate is null) return BadRequest(new { Error = "no estimate linked to this query" }); - 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 + var type = role == 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); } @@ -265,22 +94,13 @@ namespace Yavsc.ApiControllers return BadRequest(new { Error = "signature stage failed", Detail = ex.Message }); } - if (actingRole == ActorRole.Provider) + if (role == ActorRole.Provider) estimate.ProviderValidationDate = DateTime.UtcNow; else estimate.ClientValidationDate = DateTime.UtcNow; } - // The party-specific status records who accepted: the - // provider (ProAccepted) or the client (ClientAccepted). - // An admin accepting (neither party) falls back to the - // legacy generic Accepted. - query.Status = actingRole switch - { - ActorRole.Provider => QueryStatus.ProAccepted, - ActorRole.Client => QueryStatus.ClientAccepted, - _ => QueryStatus.Accepted, - }; + query.Status = QueryStatus.Accepted; try { @@ -370,3 +190,28 @@ namespace Yavsc.ApiControllers internal enum ActorRole { None, Provider, Client, Admin } } + +namespace Yavsc.ViewModels.FrontOffice +{ + /// + /// Body of POST /api/v1/front/query/accept and + /// .../reject. The field is optional: + /// when present (non-empty) on accept, it is the PostIt wire-format + /// signature captured with the acceptance. All fields are optional + /// so a caller may post {} to accept/reject without a + /// signature. + /// + public class QueryAcceptanceRequest + { + /// + /// Wire-format strokes. See + /// PostIt.Models.SignaturePadData. Null or empty means + /// no signature. + /// + public int[]? Strokes { get; set; } + + public int CoordinateMax { get; set; } = 10_000; + + public DateTime? CapturedAtUtc { get; set; } + } +} \ No newline at end of file diff --git a/src/Yavsc.Api/Helpers/TeXHelpers.cs b/src/Yavsc.Api/Helpers/TeXHelpers.cs deleted file mode 100644 index c35806bda..000000000 --- a/src/Yavsc.Api/Helpers/TeXHelpers.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Text; -using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.ViewEngines; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Yavsc.Models.Billing; - -namespace Yavsc.Helpers -{ - using Microsoft.AspNetCore.Mvc.Razor; - using ViewModels.Gen; - public class TeXString : HtmlString - { - - public TeXString(TeXString teXString): base(teXString.ToString()) - { - } - - public TeXString(string str) : base(str) - { - - - } - - public static TeXString operator+ (TeXString a, TeXString b) { - return new TeXString(a.ToString()+b.ToString()); - } - - } - public class Replacement - { - string target; - string replacement; - public Replacement(string target, string replacement) - { - this.target = target; - this.replacement = replacement; - } - public string Execute(string source) - { - return source?.Replace(target, replacement) ?? null; - } - } - public static class TeXHelpers - { - public static readonly Replacement[] SpecialCharsDefaultRendering = - { - new Replacement("<","\\textless"), - new Replacement(">","\\textgreater"), - new Replacement("©","\\copyright"), - new Replacement("®","\\textregistered"), - new Replacement("\\","\\textbackslash"), - new Replacement("™","\\texttrademark"), - new Replacement("¶","\\P"), - new Replacement("|","\\textbar"), - new Replacement("%","\\%"), - new Replacement("{","\\{"), - new Replacement("}","\\}"), - new Replacement("_","\\_"), - new Replacement("#","\\#"), - new Replacement("$","\\$"), - new Replacement("_","\\_"), - new Replacement("¿","\\textquestiondown"), - new Replacement("§","\\S"), - new Replacement("£","\\pounds"), - new Replacement("&","\\&"), - new Replacement("¡","\\textexclamdown"), - new Replacement("†","\\dag"), - new Replacement("–","\\textendash"), - new Replacement("°","\\textdegree") - }; - - public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}") - { - if (source==null) return new TeXString(defaultValue); - string result=source; - foreach (var r in SpecialCharsDefaultRendering) - { - result = r.Execute(result); - } - return new TeXString(result); - } - - public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}") - { - if (source==null) return new TeXString(defaultValue); - string result=source; - foreach (var r in SpecialCharsDefaultRendering) - { - result = r.Execute(result); - } - result = result.Replace("\n","\\tabularnewline "); - return new TeXString(result); - } - - - public static string NewLinesWith(this string target, string separator) - { - var items = target.Split(new char[] { '\n' }).Where( - s => !string.IsNullOrWhiteSpace(s)); - - return string.Join(separator, items); - } - - public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\") - { - if (source == null) return new TeXString(defaultValue); - return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) ); - } - - public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}") - { - if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue); - var alines = source.Split(','); - var texlines = alines.Select(l=>l.ToTeX().ToString()); - return new TeXString(string.Join(lineSeparator,texlines)); - } - - /// - /// Renders a captured signature as TikZ \draw/\fill - /// paths, suitable for embedding in a tikzpicture at the - /// foot of a devis. The signature is the PostIt wire format — a - /// length-prefixed sequence of strokes, each stroke being - /// [k, x0, y0, x1, y1, …] with coordinates normalised to - /// [0, CoordinateMax] and y growing downward (screen - /// orientation). We denormalise to a × - /// canvas and flip y so the signature - /// appears upright on paper. A single-point stroke (a pen-down - /// tap) is rendered as a filled dot, mirroring the round-capped - /// Polyline the Avalonia pad draws for it. - /// - /// The persisted signature. Null/empty yields - /// , so the caller can gate the - /// tikzpicture on a non-empty result. - public static HtmlString SignatureToTikz( - this Signature sig, double widthCm, double heightCm) - { - if (sig is null || sig.Strokes is null || sig.Strokes.Length == 0) - return HtmlString.Empty; - - double max = sig.CoordinateMax > 0 ? sig.CoordinateMax : 10_000.0; - var strokes = sig.Strokes; - var sb = new StringBuilder(); - - int i = 0; - while (i < strokes.Length) - { - int k = strokes[i]; - if (k <= 0) break; - if (i + 1 + 2 * k > strokes.Length) break; - i++; // skip the length prefix - - // A single-point stroke: a tap. Draw a round dot so it - // shows instead of a zero-length, invisible \draw. - if (k == 1) - { - double px = strokes[i] / max * widthCm; - double py = heightCm - strokes[i + 1] / max * heightCm; - sb.Append(CultureInfo.InvariantCulture, - $"\\fill ({px:F3},{py:F3}) circle (0.8pt); "); - i += 2; - continue; - } - - sb.Append("\\draw[line cap=round, line join=round, line width=1.5pt] "); - for (int p = 0; p < k; p++) - { - double px = strokes[i + 2 * p] / max * widthCm; - double py = heightCm - strokes[i + 2 * p + 1] / max * heightCm; - if (p == 0) sb.Append(CultureInfo.InvariantCulture, - $"({px:F3},{py:F3})"); - else sb.Append(CultureInfo.InvariantCulture, - $" -- ({px:F3},{py:F3})"); - } - sb.Append("; "); - i += 2 * k; - } - - return new HtmlString(sb.ToString()); - } - - public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model) - { - string errorMsg = null; - // 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 (/bills/bills) and - // the log/pdf write would fail. - var billdir = new DirectoryInfo(Model.DestDir).FullName; - string name = Model.BaseFileName; - FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf")); - System.IO.Directory.CreateDirectory(billdir); - - // Compile the LaTeX source with the system lualatex, feeding it - // the TeX on standard input (no intermediate .tex file) and - // directing the resulting .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. - // - // We pass "/dev/stdin" as the input filename so lualatex treats - // stdin as a regular input *file* rather than the terminal: with - // no filename argument it reads stdin in terminal mode, which - // emergency-stops after the first line of a multi-line document. - // /dev/stdin makes the full multi-line TeX compile cleanly. (The - // API host is Linux — the systemd service and lualatex dep are - // Linux-only too.) - // - // We capture stdout/stderr so a LaTeX error (missing .sty, - // missing \includegraphics target, …) is surfaced in - // GenerationErrorMessage instead of being buried in the .log - // we then delete. - int exitCode; - string stdout = null, stderr = null; - try - { - using (Process p = new Process()) - { - p.StartInfo = new ProcessStartInfo - { - UseShellExecute = false, - WorkingDirectory = billdir, - FileName = "lualatex", - Arguments = $"-interaction=nonstopmode -halt-on-error" - + $" -jobname=\"{name}\" -output-directory=\"{billdir}\"" - + " /dev/stdin", - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - p.Start(); - // Write the TeX source, then drain stdout/stderr. Read - // them after WaitForExit — lualatex's output is small - // (a few KB), well under the pipe buffer, so no deadlock. - using (var stdin = p.StandardInput) - { - stdin.Write(Model.TeXSource); - } - p.WaitForExit(); - exitCode = p.ExitCode; - stdout = p.StandardOutput.ReadToEnd(); - stderr = p.StandardError.ReadToEnd(); - } - } - catch (Exception ex) - { - errorMsg = "lualatex invocation failed: " + ex.Message; - exitCode = -1; - } - - if (exitCode != 0 && errorMsg == null) - { - // Surface the engine's own diagnostics (stderr carries the - // "! Emergency stop" / "! LaTeX Error: …" lines; stdout has - // the transcript banner) so the caller can see why it failed - // without having to find the (soon-deleted) .log file. - var diag = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr; - errorMsg = $"Pdf generation failed with exit code {exitCode}: {diag}"; - } - else if (!fo.Exists && errorMsg == null) - errorMsg = "Pdf generation produced no output"; - - // Remove the LaTeX build artifacts left beside the pdf so only - // .pdf persists in the bills directory. Keep the .log on - // failure so it can be inspected. - foreach (var ext in new[] { ".aux", ".log", ".out", ".fls", - ".fdb_latexmk", ".synctex.gz", ".toc" }) - { - if (ext == ".log" && !fo.Exists) continue; - var fileName = name + ext; - var f = new FileInfo(System.IO.Path.Combine(billdir, fileName)); - if (f.Exists) { try { f.Delete(); } catch { } } - } - - Model.Generated = fo.Exists; - Model.GenerationErrorMessage = new HtmlString(errorMsg); - return fo; - } - - public static string RenderViewToString( - this Controller controller, IRazorViewEngine engine, - 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()) - { - var viewContext = new ViewContext( - actionContext, - viewResult.View, - controller.ViewData, - controller.TempData, - sw, - new HtmlHelperOptions()); - viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult(); - return sw.GetStringBuilder().ToString(); - } - } - } -} diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 8daff34d9..be4ae6579 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -55,11 +55,7 @@ internal class Program }); }) .AddYavscCors(builder.Configuration) - .AddControllersWithViews(); - - // Binds the "Site" section (Bills, TempDir, Avatars, …) used by - // the document-generation flow and BillingController. - services.Configure(builder.Configuration.GetSection("Site")); + .AddControllers(); // accepts any access token issued by identity server services.AddAuthentication("Bearer") diff --git a/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs b/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs deleted file mode 100644 index 41e4dd650..000000000 --- a/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Yavsc.Models.Billing; - -namespace Yavsc.ViewModels.FrontOffice -{ - /// - /// Body of POST /api/v1/front/query/accept and - /// .../reject. The field is optional: - /// when present (non-empty) on accept, it is the PostIt wire-format - /// signature captured with the acceptance. All fields are optional - /// so a caller may post {} to accept/reject without a - /// signature. - /// - public class QueryAcceptanceRequest - { - /// - /// Wire-format strokes. See - /// PostIt.Models.SignaturePadData. Null or empty means - /// no signature. - /// - public int[]? Strokes { get; set; } - - public int CoordinateMax { get; set; } = 10_000; - - public DateTime? CapturedAtUtc { get; set; } - - /// - /// Which signature is being submitted: - /// (the provider) or - /// . When - /// is present this declares the side the caller is signing as; - /// the server honors it only if the caller is that party - /// (query.PerformerId for Pro, query.ClientId - /// 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. - /// - public SignatureType? SignatureType { get; set; } - } -} diff --git a/src/Yavsc.Api/Views/_ViewImports.cshtml b/src/Yavsc.Api/Views/_ViewImports.cshtml deleted file mode 100644 index 788aed5ab..000000000 --- a/src/Yavsc.Api/Views/_ViewImports.cshtml +++ /dev/null @@ -1,12 +0,0 @@ -@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 \ No newline at end of file diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index 72231b0af..1a2accfc8 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -7,8 +7,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 4b3abb75c..14b41e9e7 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -9,8 +9,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -32,4 +32,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 204ae6c73..28b5841c9 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 51d3660a9..533333c3b 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -11,8 +11,8 @@ $(MSBuildProjectDirectory)\test.runsettings 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -88,4 +88,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs index ac97193d0..549d80942 100644 --- a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs +++ b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs @@ -8,6 +8,7 @@ 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; @@ -20,20 +21,22 @@ namespace Yavsc.Controllers readonly ILogger _logger; readonly IStringLocalizer _SR; private readonly IBillingService _billing; + private readonly SiteSettings _siteSettings; public FrontOfficeController(ApplicationDbContext context, UserManager userManager, IBillingService billing, ILoggerFactory loggerFactory, - IStringLocalizer SR) + IStringLocalizer SR, + IOptions siteSettings) { _context = context; _userManager = userManager; _logger = loggerFactory.CreateLogger(); _SR = SR; _billing = billing; + _siteSettings = siteSettings.Value; } - public ActionResult Index() { var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -43,9 +46,9 @@ namespace Yavsc.Controllers { EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted && c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(), - EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && (c.Status == QueryStatus.ProAccepted || c.Status == QueryStatus.Accepted) + EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted && c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(), - EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && (e.Query.Status == QueryStatus.ProAccepted || e.Query.Status == QueryStatus.Accepted)).Count(), + EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(), BillToSignAsCliCount = 0, NewPayementsCount = 0 @@ -77,5 +80,61 @@ 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(); + } } } diff --git a/src/Yavsc.Org/Helpers/TeXHelpers.cs b/src/Yavsc.Org/Helpers/TeXHelpers.cs new file mode 100644 index 000000000..f7cc812fb --- /dev/null +++ b/src/Yavsc.Org/Helpers/TeXHelpers.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; + +namespace Yavsc.Helpers +{ + using Microsoft.AspNetCore.Mvc.Infrastructure; + using ViewModels.Gen; + public class TeXString : HtmlString + { + + public TeXString(TeXString teXString): base(teXString.ToString()) + { + } + + public TeXString(string str) : base(str) + { + + + } + + public static TeXString operator+ (TeXString a, TeXString b) { + return new TeXString(a.ToString()+b.ToString()); + } + + } + public class Replacement + { + string target; + string replacement; + public Replacement(string target, string replacement) + { + this.target = target; + this.replacement = replacement; + } + public string Execute(string source) + { + return source?.Replace(target, replacement) ?? null; + } + } + public static class TeXHelpers + { + public static readonly Replacement[] SpecialCharsDefaultRendering = + { + new Replacement("<","\\textless"), + new Replacement(">","\\textgreater"), + new Replacement("©","\\copyright"), + new Replacement("®","\\textregistered"), + new Replacement("\\","\\textbackslash"), + new Replacement("™","\\texttrademark"), + new Replacement("¶","\\P"), + new Replacement("|","\\textbar"), + new Replacement("%","\\%"), + new Replacement("{","\\{"), + new Replacement("}","\\}"), + new Replacement("_","\\_"), + new Replacement("#","\\#"), + new Replacement("$","\\$"), + new Replacement("_","\\_"), + new Replacement("¿","\\textquestiondown"), + new Replacement("§","\\S"), + new Replacement("£","\\pounds"), + new Replacement("&","\\&"), + new Replacement("¡","\\textexclamdown"), + new Replacement("†","\\dag"), + new Replacement("–","\\textendash"), + new Replacement("°","\\textdegree") + }; + + public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}") + { + if (source==null) return new TeXString(defaultValue); + string result=source; + foreach (var r in SpecialCharsDefaultRendering) + { + result = r.Execute(result); + } + return new TeXString(result); + } + + public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}") + { + if (source==null) return new TeXString(defaultValue); + string result=source; + foreach (var r in SpecialCharsDefaultRendering) + { + result = r.Execute(result); + } + result = result.Replace("\n","\\tabularnewline "); + return new TeXString(result); + } + + + public static string NewLinesWith(this string target, string separator) + { + var items = target.Split(new char[] { '\n' }).Where( + s => !string.IsNullOrWhiteSpace(s)); + + return string.Join(separator, items); + } + + public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\") + { + if (source == null) return new TeXString(defaultValue); + return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) ); + } + + public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}") + { + if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue); + var alines = source.Split(','); + var texlines = alines.Select(l=>l.ToTeX().ToString()); + return new TeXString(string.Join(lineSeparator,texlines)); + } + + public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model) + { + string errorMsg = null; + var billdir = Model.DestDir; + var tempdir = settings.TempDir; + 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 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 + { + 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}" + }; + p.Start(); + 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); + + } + } + } + Model.Generated = fo.Exists; + Model.GenerationErrorMessage = new HtmlString(errorMsg); + return fo.Exists; + } + + public static string RenderViewToString( + this Controller controller, IViewEngine engine, + IActionContextAccessor contextAccessor, + string viewName, object model, bool isMainPage = true) + { + 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(); + return sw.GetStringBuilder().ToString(); + } + } + } +} diff --git a/src/Yavsc.Org/ViewComponents/BillViewComponent.cs b/src/Yavsc.Org/ViewComponents/BillViewComponent.cs new file mode 100644 index 000000000..0217ebcf2 --- /dev/null +++ b/src/Yavsc.Org/ViewComponents/BillViewComponent.cs @@ -0,0 +1,108 @@ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Yavsc.Billing; +using Yavsc.Helpers; +using Yavsc.Models; +using Yavsc.ViewModels; +using Yavsc.ViewModels.Gen; +using Yavsc.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Yavsc.ViewComponents +{ + public class BillViewComponent : ViewComponent + { + readonly ApplicationDbContext dbContext; + readonly IBillingService billing; + readonly IStringLocalizer localizer; + private readonly SiteSettings siteSettings; + + public BillViewComponent(ApplicationDbContext dbContext, + IStringLocalizer localizer, + IBillingService billing, + IOptions siteSettings) + { + this.billing = billing; + this.dbContext = dbContext; + this.localizer = localizer; + this.siteSettings = siteSettings.Value; + } + + public async Task InvokeAsync( + string code, + IBillable billable, + OutputFormat format, + bool asBill, + SiteSettings settings + ) + { + var di = new DirectoryInfo(settings.Bills); + var dia = new DirectoryInfo(settings.Avatars); + ViewBag.BillsDir = di.FullName; + ViewBag.AvatarsDir = dia.FullName; + ViewBag.AsBill = asBill; // vrai pour une facture, sinon, c'est un devis + ViewBag.Acquitted = billable.GetIsAcquitted(); + + ViewBag.BillingCode = code; + var client = await dbContext.Users + .Include(u=>u.PostalAddress) + .SingleAsync(u=>u.Id == billable.ClientId); + ViewBag.Client = client; + var performer = await dbContext.Users + .Include(u=>u.BankInfo) + .Include(u=>u.PostalAddress) + .SingleAsync(u=>u.Id == billable.PerformerId); + ViewBag.Performer = performer; + string clientAddress = client.PostalAddress?.Address ?? null; + ViewBag.ClientAddress = clientAddress.SplitAddressToTeX(); + + + var profile = await dbContext.Performers + .Include(p=>p.OrganizationAddress) + .SingleAsync(p=>p.PerformerId == billable.PerformerId); + ViewBag.PerformerProfile = profile; + ViewBag.ActivityLabel = (await dbContext.Activities.SingleAsync(a => a.Code == billable.ActivityCode)).Name; + + var proAddr = profile.OrganizationAddress.Address; + ViewBag.PerformerOrganizationAddress = proAddr.SplitAddressToTeX() ; + ViewBag.FooterPerformerOrganizationAddress = proAddr.SplitAddressToTeX(", "); + + ViewBag.PerformerAddress = performer.PostalAddress?.Address.SplitAddressToTeX() ; + switch (format) { + case OutputFormat.LaTeX : + return this.View("Bill_tex", billable); + case OutputFormat.Pdf : + string tex = null; + var oldWriter = ViewComponentContext.ViewContext.Writer; + using (var writer = new StringWriter()) + { + this.ViewComponentContext.ViewContext.Writer = writer; + var resultTex = View("Bill_tex", billable); + await resultTex.ExecuteAsync(this.ViewComponentContext); + tex = writer.ToString(); + } + ViewComponentContext.ViewContext.Writer = oldWriter; + + var pdfGenerationViewModel = new PdfGenerationViewModel + { + Temp = siteSettings.TempDir, + TeXSource = tex, + DestDir = siteSettings.Bills, + BaseFileName = billable.GetFileBaseName(billing) + }; + if (settings.GenerateEstimatePdf(pdfGenerationViewModel)) { + return this.View(new { Generated = pdfGenerationViewModel.BaseFileName+".pdf" }); + } else { + return View(new { Error = pdfGenerationViewModel.GenerationErrorMessage } ); + } + } + ViewBag.BillFileInfo = billable.GetBillInfo(billing, siteSettings); + ViewBag.Settings = settings; + return View("Default",billable); + + } + + } +} diff --git a/src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs b/src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs similarity index 98% rename from src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs rename to src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs index 841e8d5dd..fe934e3ac 100644 --- a/src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs +++ b/src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs @@ -5,7 +5,7 @@ namespace Yavsc.ViewModels.Gen { public class PdfGenerationViewModel { - + [Required] public string TeXSource { get; set; } [Required] diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml new file mode 100644 index 000000000..f49fad802 --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml @@ -0,0 +1,21 @@ +@using System.Reflection +@using System.IO +@using Microsoft.Extensions.WebEncoders +@using System.Diagnostics +@using System.Text +@using Yavsc.Helpers +@model Yavsc.ViewModels.Gen.PdfGenerationViewModel + +@{ + ViewBag.Settings.GenerateEstimatePdf(Model); +} +@if (Model.Generated) { +
+ @(Model.BaseFileName).pdf was generated +
+} else { +
+ @Model.GenerationErrorMessage +

Something went wrong ...

+
+} diff --git a/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml similarity index 99% rename from src/Yavsc.Api/Views/Shared/Bill_tex.cshtml rename to src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml index f0a3d2ad3..353d124e0 100644 --- a/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml @@ -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; diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml new file mode 100644 index 000000000..f2d0e0163 --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml @@ -0,0 +1,33 @@ + +@model IBillable +
+@if (ViewBag.BillFileInfo.Exists) { +La facture au format Pdf +} else { + +
+
+ + +
+
+
+
+} +
diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml new file mode 100644 index 000000000..b9a1ab26d --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml @@ -0,0 +1,13 @@ + +@model Estimate + diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml new file mode 100644 index 000000000..bef19b35a --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml @@ -0,0 +1,21 @@ +@using System.Reflection +@using System.IO +@using Microsoft.Extensions.WebEncoders +@using System.Diagnostics +@using System.Text +@using Yavsc.Helpers +@model Yavsc.ViewModels.Gen.PdfGenerationViewModel + +@{ + SiteSettings.Value.GenerateEstimatePdf(Model); +} +@if (Model.Generated) { +
+ @(Model.BaseFileName).pdf was generated +
+} else { +
+ @Model.GenerationErrorMessage +

Something went wrong ...

+
+} diff --git a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml similarity index 72% rename from src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml rename to src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml index bbe4326b4..4c35d4bdd 100644 --- a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml @@ -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; @@ -11,6 +11,7 @@ var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var isestimate = !ViewBag.AsBill; + var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png"); } \documentclass[french,11pt]{article} @@ -24,7 +25,6 @@ \usepackage{graphicx} \usepackage{fancyhdr} \usepackage{fp} -\usepackage{tikz} \def\TVA{20} % Taux de la TVA @@ -49,7 +49,7 @@ \global\let\TotalHT\TotalHT \global\let\TotalTVA\TotalTVA \global\let\TotalTTC\TotalTTC - \cr + \cr \hline \textbf{Total} & & & \TotalHT } @@ -77,12 +77,12 @@ % Infos Client \def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client -\def\ClientAdresse{ +\def\ClientAdresse{ % Adresse du client @PostalAddress @if (PostalAddress!=null) {@PostalAddress} @if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {@TeXHelpers.ToTeX(to.PhoneNumber)\\ - } + } E-mail: @TeXHelpers.ToTeX(to.Email) } @@ -105,7 +105,7 @@ \renewcommand{\headrulewidth}{0pt} \cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { - @proaddrm } \newline - \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } + \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } } } @@ -113,10 +113,10 @@ % Logo de la société @if (from.Avatar != null) { -\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png} +\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png} -} else { -%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png} +} else { +%\includegraphics{logo.png} } % Nom et adresse de la société @@ -170,12 +170,12 @@ \begin{center} \begin{tabular}{|c c c c|} @if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode) - && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { + && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { \hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\ @bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\ } - @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { + @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { \hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\ \hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC } @@ -188,46 +188,19 @@ } -@{ - // Captured signatures, if any. The Estimate.Signatures collection - // is loaded by the controller; SignaturePro/SignatureClient project - // the row by type. We draw them as vector TikZ at the foot of the - // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to - // produce or \includegraphics to fail on. - var fr = CultureInfo.CreateSpecificCulture("fr-FR"); - var proSig = Model.SignaturePro; - var cliSig = Model.SignatureClient; +@if (!(Model.ProviderValidationDate==null)) { + +\begin{flushright} + @(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) +\end{flushright} +@if (prosign.Exists) { + +\begin{center} + \hspace{263pt} + \includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png} +\end{center} + } - -\vspace{2em} -\noindent -\begin{minipage}[t]{0.45\textwidth} - \textbf{Signature du fournisseur}\\ -@if (Model.ProviderValidationDate != default(DateTime)) { -\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ + } -@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) { -\begin{tikzpicture} - \useasboundingbox (0,0) rectangle (5,2.5); - @proSig.SignatureToTikz(5, 2.5) -\end{tikzpicture} -} else { -\rule[-2.2cm]{5cm}{0.4pt} -} -\end{minipage}\hfill -\begin{minipage}[t]{0.45\textwidth} - \textbf{Signature du client}\\ -@if (Model.ClientValidationDate != default(DateTime)) { -\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ -} -@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) { -\begin{tikzpicture} - \useasboundingbox (0,0) rectangle (5,2.5); - @cliSig.SignatureToTikz(5, 2.5) -\end{tikzpicture} -} else { -\rule[-2.2cm]{5cm}{0.4pt} -} -\end{minipage} - \end{document} diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 99e0cf29c..928faf7c8 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -9,8 +9,8 @@ https://github.com/pazof/yavsc 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -47,4 +47,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index 77e691ea5..bc7b9c81d 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -34,7 +34,7 @@ namespace Yavsc.Server.Helpers { var item = new FileReceivedInfo( settings.Bills, - AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId)); + AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId)); var fi = new FileInfo(item.FullName); if (fi.Exists) item.Overridden = true; diff --git a/src/Yavsc.Server/Models/Billing/Estimate.cs b/src/Yavsc.Server/Models/Billing/Estimate.cs index 8a26faab9..74680e7b7 100644 --- a/src/Yavsc.Server/Models/Billing/Estimate.cs +++ b/src/Yavsc.Server/Models/Billing/Estimate.cs @@ -8,7 +8,6 @@ namespace Yavsc.Models.Billing { using Models.Workflow; using Newtonsoft.Json; - using System.Linq; public partial class Estimate : IEstimate { @@ -79,39 +78,14 @@ namespace Yavsc.Models.Billing public DateTime ClientValidationDate { get; set; } /// - /// All signatures captured against this estimate, keyed by - /// . The persister upserts by - /// (EstimateId, Type), so there is at most one row per type. - /// This collection is the persisted shape and stays - /// [JsonIgnore]: the wire payload exposes signatures - /// only through the dedicated and - /// projections, since an - /// estimate has exactly these two signature slots and never - /// more. See . + /// 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 . /// [InverseProperty(nameof(Signature.Estimate))] - [JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual ICollection Signatures { get; set; } = new List(); - /// - /// The provider's signature on this estimate, or null when the - /// provider has not signed. Projected from - /// ; not mapped (the table is - /// ). - /// - [NotMapped] - public Signature? SignaturePro - => Signatures?.FirstOrDefault(s => s.Type == SignatureType.Pro); - - /// - /// The client's signature on this estimate, or null when the - /// client has not signed. Projected from - /// ; not mapped. - /// - [NotMapped] - public Signature? SignatureClient - => Signatures?.FirstOrDefault(s => s.Type == SignatureType.Client); - } } diff --git a/src/Yavsc.Server/Models/Billing/Signature.cs b/src/Yavsc.Server/Models/Billing/Signature.cs index 7e568dbd1..1aac0cd0e 100644 --- a/src/Yavsc.Server/Models/Billing/Signature.cs +++ b/src/Yavsc.Server/Models/Billing/Signature.cs @@ -31,7 +31,7 @@ public class Signature public long EstimateId { get; set; } - [ForeignKey(nameof(EstimateId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] + [ForeignKey(nameof(EstimateId)), JsonIgnore] public virtual Estimate Estimate { get; set; } /// @@ -48,7 +48,7 @@ public class Signature [Required] public string SignerId { get; set; } - [ForeignKey(nameof(SignerId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] + [ForeignKey(nameof(SignerId)), JsonIgnore] public virtual ApplicationUser Signer { get; set; } public SignatureType Type { get; set; } diff --git a/src/Yavsc.Abstract/Billing/SignatureType.cs b/src/Yavsc.Server/Models/Billing/SignatureType.cs similarity index 99% rename from src/Yavsc.Abstract/Billing/SignatureType.cs rename to src/Yavsc.Server/Models/Billing/SignatureType.cs index 5a27f5cdd..7c27924fd 100644 --- a/src/Yavsc.Abstract/Billing/SignatureType.cs +++ b/src/Yavsc.Server/Models/Billing/SignatureType.cs @@ -12,4 +12,4 @@ public enum SignatureType { Pro = 0, Client = 1, -} \ No newline at end of file +} diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index b291847c0..bb2022fc6 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -9,8 +9,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index 830377ca2..dd93a8b34 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,8 +14,8 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 39ef7660f..382dff670 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,8 +7,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d - 1.1.0-estimate.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1