Compare commits

...
Author SHA1 Message Date
fbf42cc1db
version update
All checks were successful
Forgejo Release / release (push) Successful in 8m17s
Dotnet build and test / build (pull_request) Successful in 6m2s
2026-09-22 18:39:19 +01:00
ba40698cc9
CHANGELOG: add 1.0.8-rc16 section
Describes the estimate signature + TeX/PDF generation release impact:
signature pad on validation, role-aware accept/reject with signatures,
ProAccepted/ClientAccepted split, estimate.tex/estimate.pdf endpoints
in Yavsc.Api, vector TikZ signatures, PostIt PDF/TeX download buttons,
list refresh + title renames, the lualatex stdin runtime, and the
Emergency stop / log-write / RenderViewToString / Layout fixes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:35:48 +01:00
35d784fdf1
Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex
treats stdin as a regular input file instead of the terminal — without
it, lualatex emergency-stops after the first line of a multi-line
document (the real root cause of the production "Emergency stop", not
bera/luaotfload which were separate host-package gaps already fixed).

EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the
returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is
extracted into its own file under ViewModels/FrontOffice.

Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector
draws of both signatures) and GenerateEstimatePdf on a devis signed by
both parties — asserts the PDF exists at the expected DestDir path, is
non-empty, and that only the PDF persists (aux/log cleaned). Skips
cleanly when lualatex is absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:33:47 +01:00
9c1f552bca
Draw estimate signatures as vector TikZ instead of PNG
The estimate foot now renders the provider and client signatures as
TikZ \draw/\fill paths straight from the captured stroke data, in two
side-by-side minipages with a \useasboundingbox so the boxes align
regardless of stroke coverage. A side that hasn't signed shows a
signature rule.

- TeXHelpers.SignatureToTikz walks the PostIt wire format
  ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down)
  and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y
  so the signature is upright on paper; a single-point tap becomes a
  filled dot, mirroring the Avalonia round-capped Polyline.
- FrontOfficeApiController.LoadEstimateForRenderAsync includes
  Estimate.Signatures so SignaturePro/SignatureClient resolve.
- Estimate_tex.cshtml drops the \includegraphics of a PNG (which
  failed the build when the file was absent) and adds \usepackage{tikz}.
- README: document texlive-pictures (tikz) and texlive-fonts-extra
  (bera) as runtime deps; bera.sty missing is the likely cause of the
  remaining host Emergency stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:45:05 +01:00
bbc37d3095
corrige la génération du Pdf 2026-09-22 16:34:51 +01:00
7caae103fe
PDF gen: capture lualatex stdout/stderr into the error message
The detailed LaTeX error (missing .sty, missing \includegraphics
target, …) was being lost: we only redirected stdin, and the engine
writes its diagnostics to the .log file we delete in cleanup. So a
generation failure surfaced only as the terse "Emergency stop" in the
journal, with no clue what actually failed.

Redirect stdout and stderr, and on non-zero exit include them in
GenerationErrorMessage so the caller sees the real engine diagnostic.
Keep the .log file on failure (only clean it on success) so it can be
inspected alongside.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:31:10 +01:00
f44dab0334
README: document lualatex runtime deps for PDF generation
texlive-binaries (lualatex) + texlive-luatex (luaotfload) are required
on the API host for estimate/bill PDF generation. Note that luaotfload
moved from texlive-latex-extra into texlive-luatex on recent Debian.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:28:52 +01:00
97d0584292
Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00
70a123b840
CLI options : -c configurqtion-file.json 2026-09-21 18:53:17 +01:00
adc27a006b
Reopen estimate: carry signature strokes on the estimate payload
Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 18:03:08 +01:00
827fe6b41f
Split QueryStatus.Accepted into ProAccepted/ClientAccepted
Distinguish the two parties' acceptances: ProAccepted (provider) and
ClientAccepted (client) replace the single Accepted value for new
acceptances. Accepted=2 is retained as a legacy value so existing DB rows
keep their integer mapping.

- FrontOfficeApiController.AcceptQuery sets status by ActorRole
- BillingController provider-ongoing filters match the new values
- FrontOfficeController (MVC) dashboard counts use ProAccepted||Accepted
- PostIt: IsOngoingStatus, GetStatusRank (client-accepted ranks first for
  the pro), and status-badge switches (P/C glyphs) handle the new values
- Tests assert ProAccepted (provider) vs ClientAccepted (client)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:32 +01:00
36ffe4683c
PostIt: validate/refuse an estimate with a signature (pro or client)
Adds the client-side ability to open an estimate from the list, draw a
signature on a reusable SignaturePadView control, and accept or reject it
against /api/v1/front/query/accept|reject. The server resolves the actor
role (provider vs client) from the caller's identity, so the same page works
for either party.

- Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject)
- PostIt: extract SignaturePadView UserControl from SignaturePage; new
  EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour;
  wire OpenEstimate entry point from EstimateListPage; register client/page
  in DI + ViewLocator
- Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests;
  update EstimateListPageViewModelTests ctor

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
77 changed files with 3027 additions and 780 deletions

View file

@ -1,5 +1,81 @@
# 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
@ -369,6 +445,8 @@ 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

View file

@ -61,6 +61,37 @@ 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`.

View file

@ -22,6 +22,7 @@
<PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
@ -30,4 +31,4 @@
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project>
</Project>

View file

@ -11,8 +11,8 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">

View file

@ -6,8 +6,8 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Browser" />

View file

@ -7,8 +7,8 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -12,6 +12,8 @@ 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

View file

@ -11,12 +11,13 @@ public class EstimateListPageViewModelTests
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
Assert.Equal("Mes devis à valider", vm.Title);
Assert.Equal("Devis en attente de signature client", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
// Most recent first
Assert.Equal(2, vm.Estimates[0].Id);
@ -28,12 +29,13 @@ public class EstimateListPageViewModelTests
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
Assert.Equal("Mes devis en attente", vm.Title);
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
}
@ -42,7 +44,8 @@ public class EstimateListPageViewModelTests
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
@ -63,7 +66,8 @@ public class EstimateListPageViewModelTests
{
var api = new StubEstimateListApi { Empty = true };
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();

View file

@ -0,0 +1,255 @@
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;
/// <summary>
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
/// signature pads (provider + client) into the right
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
/// is not tested here; the VM is driven through the same
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
/// and the API client through a recording fake
/// <see cref="IYavscApiClient"/>. The test host's
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
/// run to completion without navigation side effects.
/// </summary>
public class EstimateValidationPageViewModelTests
{
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
{
Id = 7,
CommandId = commandId,
CommandType = "Rdv",
Title = "Devis plomberie",
Description = "Réparation",
ClientId = "bob",
OwnerId = "alice",
};
private static void DrawOneStroke(SignaturePadControl pad)
{
pad.AppendPointForTest(100, 200);
pad.AppendPointForTest(300, 400);
pad.SealStrokeForTest();
}
[Fact]
public async Task Validate_calls_accept_with_strokes_and_command_id()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var 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<QueryAcceptanceRequestDto>(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<QueryAcceptanceRequestDto>(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<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,157 @@
using System.Net.Http;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
/// the PostIt signature payload, using a recording fake
/// <see cref="IYavscApiClient"/> (no network).
/// </summary>
public class FrontOfficeApiClientTests
{
private static QueryAcceptanceRequestDto SampleBody() => new()
{
Strokes = new[] { 2, 100, 200, 300, 400 },
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var body = SampleBody();
await client.AcceptQueryAsync("Rdv", 42, body);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Same(body, api.LastBody);
}
[Fact]
public async Task Reject_posts_to_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.RejectQueryAsync("Rdv", 42);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Accept_returns_deserialized_response()
{
var api = new RecordingApi
{
Response = new QueryAcceptanceResponseDto
{
QueryId = 42,
Status = "Accepted",
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
},
};
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
Assert.Equal(42, response.QueryId);
Assert.Equal("Accepted", response.Status);
Assert.NotNull(response.Signature);
Assert.Equal("Pro", response.Signature!.Type);
}
[Fact]
public async Task Accept_rejects_invalid_arguments()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
}
[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<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
var response = Response ?? new QueryAcceptanceResponseDto();
return Task.FromResult((T)(object)response);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = null;
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -8,8 +8,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
@ -26,4 +26,4 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup></ItemGroup>
</Project>
</Project>

View file

@ -17,6 +17,7 @@ namespace PostIt;
public partial class App : Application
{
private int _bootStarted;
public string? CliConfigFileSpecification {  get; private set; }
/// <summary>
/// DI container the platform entry points hand to ViewModels so
@ -203,4 +204,9 @@ public partial class App : Application
{
await View!.NavRoot.PopAsync();
}
internal void UseConfigFileWhenLoading(string configFile)
{
this.CliConfigFileSpecification = configFile;
}
}

View file

@ -0,0 +1,33 @@
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<string> 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);
}
}
}

View file

@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value);
}
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary>
/// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea);
_capturing = true;
@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> 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.
/// </summary>
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);
}
/// <summary>
/// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -0,0 +1,28 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Controls.SignaturePadView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--
Capture surface. The SignaturePadControl is render-agnostic,
so we host it inside a Border that is the capture area, and
overlay a Canvas for the visual feedback. The code-behind
repaints the canvas on every RedrawRequested. The Border size
is driven by the CaptureWidth / CaptureHeight styled properties.
-->
<Border x:Name="PadFrame"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="InnerPad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Models;
namespace PostIt.Controls;
/// <summary>
/// Reusable signature-capture surface: wraps the render-agnostic
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
/// the code-behind repaints from the control's stroke buffer. Owns
/// the ink-rendering logic so every page that captures a signature
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
/// ...) shares one implementation.
///
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
/// (DIPs, defaults 600×200) to size the surface. View models attach
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
/// read snapshots, drive <c>Clear</c>, and subscribe to
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
/// contract the headless tests use, since a <see cref="UserControl"/>
/// needs its XAML loaded to be drivable.
/// </summary>
public partial class SignaturePadView : UserControl
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
// Last size the ink layer was painted at. LayoutUpdated repaints
// only when this changes, so adding/removing polyline children
// (which invalidates layout) does not loop.
private Size _lastPaintedSize;
public static readonly StyledProperty<double> CaptureWidthProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
public static readonly StyledProperty<double> CaptureHeightProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
public double CaptureWidth
{
get => GetValue(CaptureWidthProperty);
set => SetValue(CaptureWidthProperty, value);
}
public double CaptureHeight
{
get => GetValue(CaptureHeightProperty);
set => SetValue(CaptureHeightProperty, value);
}
/// <summary>
/// The underlying capture control. View models attach to this to
/// read <see cref="SignaturePadControl.Snapshot"/> / call
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
/// events; this view repaints the ink layer from the same buffer.
/// </summary>
public SignaturePadControl SignaturePad => InnerPad;
public SignaturePadView()
{
InitializeComponent();
// Wire the capture area: the Pad is the control, the
// surrounding Border (PadFrame) is the hit-test region. For an
// inline control with no template, capture wiring happens on
// first measure, which is guaranteed before the user can
// interact, so attaching here is safe.
InnerPad.CaptureArea = PadFrame;
ApplyFrameSize();
InnerPad.RedrawRequested += OnPadRedrawRequested;
// 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();
/// <summary>Defensive copy of the current buffer.</summary>
public SignaturePadData Snapshot() => InnerPad.Snapshot();
/// <summary>Forget every captured stroke.</summary>
public void Clear() => InnerPad.Clear();
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
/// <summary>
/// The effective ink surface size: the arranged bounds when
/// available, otherwise the explicitly sized
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
/// latter covers a repaint triggered before the first layout pass
/// (e.g. loading an existing signature on reopen), when Bounds is
/// still empty.
/// </summary>
private Size EffectivePadSize()
{
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
return default;
return new Size(w, h);
}
private void Repaint()
{
if (InkLayer is null) return;
var size = EffectivePadSize();
if (size == default) return;
_lastPaintedSize = size;
InkLayer.Children.Clear();
var w = 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<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}

View file

@ -0,0 +1,61 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace PostIt.Helpers;
/// <summary>
/// Saves a downloaded byte payload to a file chosen by the user through
/// the platform save picker. This is the first file-save (as opposed to
/// file-open) helper in PostIt; the open picker is already used for blog
/// attachment and avatar uploads
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
///
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
/// both the desktop and single-view lifetimes.</para>
/// </summary>
public static class FileSaveHelpers
{
/// <summary>
/// Prompt the user to choose a destination and write
/// <paramref name="content"/> to it. Returns false when the user
/// cancels the picker; throws on IO/platform errors so the caller
/// can surface them.
/// </summary>
public static async Task<bool> SaveAsync(
string suggestedName,
string defaultExtension,
byte[] content,
CancellationToken ct = default)
{
var view = (Application.Current as App)?.View;
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
if (topLevel is null)
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Enregistrer sous",
DefaultExtension = defaultExtension,
SuggestedFileName = suggestedName,
FileTypeChoices = new[]
{
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
{
Patterns = new[] { $"*.{defaultExtension}" }
}
}
}).ConfigureAwait(true);
if (file is null)
return false; // user cancelled
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
await stream.WriteAsync(content, ct).ConfigureAwait(true);
return true;
}
}

View file

@ -31,6 +31,7 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -63,6 +64,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
// ViewModels
services.AddSingleton(settings);
@ -74,6 +76,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();

View file

@ -5,8 +5,8 @@
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@ -36,6 +36,7 @@
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
return "Avatar mis à jour.";
}
/// <summary>
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
/// contract as <see cref="SetAvatarAsync"/>.
/// </summary>
public virtual async Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public async Task LogoutAsync()
{
_store.Clear();

View file

@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
Activity,
Performer,
Form,
_billingClient,
frontClient,
details,
IsReadOnly);
await app.PushPageAsync(vm).ConfigureAwait(true);
@ -171,5 +174,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
}
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
=> status is QueryStatus.Inserted
or QueryStatus.Accepted
or QueryStatus.ProAccepted
or QueryStatus.ClientAccepted
or QueryStatus.InProgress;
}

View file

@ -14,6 +14,7 @@ namespace PostIt.ViewModels;
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly BillingQueryDetailsDto _details;
public ActivityInfo Activity { get; }
@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient,
FrontOfficeApiClient frontClient,
BillingQueryDetailsDto details,
bool isReadOnly)
{
@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly;
@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true);
}
/// <summary>
/// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_details.Id),
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate linked to this command as a compiled PDF
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
/// <c>texi2pdf</c> on the server host.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_details.Id),
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private static string EmptyAsPlaceholder(string? value, string placeholder)
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
@ -166,6 +216,8 @@ 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",
@ -177,6 +229,8 @@ 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",
@ -188,6 +242,8 @@ 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",
@ -199,6 +255,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch
{
QueryStatus.Accepted => "OK",
QueryStatus.ProAccepted => "P",
QueryStatus.ClientAccepted => "C",
QueryStatus.InProgress => "~",
QueryStatus.Rejected => "!",
QueryStatus.Failed => "X",

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
@ -18,10 +19,10 @@ namespace PostIt.ViewModels;
/// </summary>
public enum EstimateListPerspective
{
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
Client,
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
Provider,
}
@ -32,14 +33,15 @@ public enum EstimateListPerspective
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _allEstimates = new();
public EstimateListPerspective Perspective => _perspective;
public string Title => _perspective == EstimateListPerspective.Client
? "Mes devis à valider"
: "Mes devis en attente";
? "Devis en attente de signature client"
: "Devis en attente de signature fournisseur";
public string Subtitle => _perspective == EstimateListPerspective.Client
? "Point de vue client"
@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
protected set { _ = value; }
}
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
public EstimateListPageViewModel(
EstimateApiClient estimateClient,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
public Task InitializeAsync() => RefreshAsync();
@ -153,4 +159,25 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open the estimate validation (accept/reject with signature)
/// page for a specific estimate. The perspective the list was
/// opened in is forwarded as the actor-role UI hint; the server
/// independently enforces the real authorization.
/// </summary>
[RelayCommand]
private async Task OpenEstimateAsync(EstimateDto? estimate)
{
if (estimate is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
}

View file

@ -0,0 +1,399 @@
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;
/// <summary>
/// Validation (accept / reject) of a single estimate, with optional
/// signatures. Backs the estimate validation page opened from
/// <see cref="EstimateListPageViewModel"/> for either perspective.
///
/// <para>Two signatures are shown, each in its own pad: the provider's
/// (Pro) and the client's (Client). Both are rendered from the estimate
/// payload when present, so reopening a partly-signed estimate shows
/// what was already captured. Only the pad matching the current
/// <see cref="EstimateListPerspective"/> is writable — the server
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
/// same rule (it resolves the caller's role from the authenticated user
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
/// the other pad read-only. The billing code sent to the server is the
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
/// its <see cref="EstimateDto.CommandId"/>.</para>
/// </summary>
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateDto _estimate;
private readonly EstimateListPerspective _perspective;
private readonly FrontOfficeApiClient _frontClient;
private SignaturePadControl? _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<EstimateLineDto> Bill => _estimate.Bill;
public DateTime ProviderValidationDate => _estimate.ProviderValidationDate;
public DateTime ClientValidationDate => _estimate.ClientValidationDate;
public string RoleLabel => _perspective == EstimateListPerspective.Provider
? "Vous validez en tant que prestataire"
: "Vous validez en tant que client";
/// <summary>
/// True when the current user may sign the provider's signature
/// (i.e. they opened the list as the provider). The other party's
/// pad is read-only.
/// </summary>
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
/// <summary>
/// True when the current user may sign the client's signature
/// (i.e. they opened the list as the client).
/// </summary>
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
/// <summary>
/// Human-readable summary of who has already validated this
/// estimate, shown above the signature pads.
/// </summary>
public string ValidationStateText
{
get
{
var parts = new List<string>();
if (ProviderValidationDate != default)
parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}");
if (ClientValidationDate != default)
parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}");
return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts);
}
}
/// <summary>
/// True when the estimate has a linked query to accept/reject.
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
/// templates and cannot be validated through the front-office
/// endpoint.
/// </summary>
public bool HasCommand => _estimate.CommandId is not null;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Stroke/point counts for the writable pad.</summary>
[ObservableProperty]
public partial int StrokeCount { get; set; }
[ObservableProperty]
public partial int PointCount { get; set; }
/// <summary>True when the writable pad holds a signature.</summary>
[ObservableProperty]
public partial bool HasSignature { get; set; }
[ObservableProperty]
public partial bool HasProSignature { get; set; }
[ObservableProperty]
public partial bool HasClientSignature { get; set; }
[ObservableProperty]
public partial string ProSignatureHint { get; set; } = string.Empty;
[ObservableProperty]
public partial string ClientSignatureHint { get; set; } = string.Empty;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateValidationPageViewModel(
EstimateDto estimate,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimate = estimate ?? throw new ArgumentNullException(nameof(estimate));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
/// <summary>
/// Bind the page's two signature pads (the inner controls of the
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
/// the client's pad. Called from the view's code-behind once the
/// view is in the visual tree. Marks the non-author pad read-only,
/// then renders any signatures already carried on the estimate
/// payload.
/// </summary>
public void Attach(SignaturePadControl 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();
/// <summary>The pad the current user may sign in.</summary>
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
/// <summary>
/// Render the signatures already stored for this estimate back into
/// their pads. The strokes are carried on the estimate payload as
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
/// its own side directly, in the same wire format PostIt captured.
/// No network round-trip.
/// </summary>
public void 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();
}
/// <summary>
/// Download the estimate as a LaTeX source document. The server
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
/// user picks a destination via the platform save picker.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate as a compiled PDF
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
/// Requires <c>texi2pdf</c> on the server host; a failure is
/// surfaced as a status message.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
if (_estimate.CommandId is not long)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
return;
}
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
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);
}
}

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
activity,
performer,
form,
_billingClient,
frontClient,
details,
isReadOnly: false);
@ -344,11 +347,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
=> status switch
{
QueryStatus.InProgress => 0,
QueryStatus.Accepted => 1,
QueryStatus.Inserted => 2,
QueryStatus.Success => 3,
QueryStatus.Rejected => 4,
QueryStatus.Failed => 5,
QueryStatus.ClientAccepted => 1,
QueryStatus.ProAccepted => 2,
QueryStatus.Accepted => 2,
QueryStatus.Inserted => 3,
QueryStatus.Success => 4,
QueryStatus.Rejected => 5,
QueryStatus.Failed => 6,
_ => 99,
};

View file

@ -90,7 +90,13 @@ public class HomePageViewModel : ViewModelBase
throw new InvalidOperationException("Client devis indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
if (frontClient is null)
{
throw new InvalidOperationException("Client front-office indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}

View file

@ -259,25 +259,13 @@ public partial class Settings : ViewModelBase
return;
}
}
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
&& !string.IsNullOrWhiteSpace(envJson))
var app = App.Current as App;
if (app!=null)
if (app.CliConfigFileSpecification!=null)
{
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;
SettingsFileFullName = app.CliConfigFileSpecification;
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
if (SettingsFileFullName is not null)
{
@ -287,10 +275,18 @@ public partial class Settings : ViewModelBase
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
&& !string.IsNullOrWhiteSpace(envPath))
{
SettingsFileFullName = envPath;
FileInfo configByEnvFileInfo = new FileInfo(envPath);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
SettingsFileFullName = configByEnvFileInfo.FullName;
}
else
{
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
}
@ -536,6 +532,7 @@ public partial class Settings : ViewModelBase
/// </summary>
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(); }
}

View file

@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
}
/// <summary>
/// Bind a freshly-constructed (or re-templated) control to this
/// VM. Called from the view's code-behind once the control has
/// been added to the visual tree and its template applied (so
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
/// Bind the inner <see cref="SignaturePadControl"/> of the page's
/// <see cref="SignaturePadView"/> to this VM. Called from the
/// page's code-behind once the view is in the visual tree (so the
/// control's <see cref="SignaturePadControl.CaptureArea"/> is
/// wired). Attaching to the control directly (rather than the
/// view) keeps the same contract the headless tests use.
/// </summary>
public void Attach(SignaturePadControl control)
{

View file

@ -14,7 +14,7 @@
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
@ -22,6 +22,12 @@
Content="Ouvrir en edition"
Command="{Binding OpenEditorCommand}"
IsVisible="{Binding CanEdit}" />
<Button Grid.Column="4"
Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Grid.Column="6"
Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
</Grid>
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">

View file

@ -29,34 +29,40 @@
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Title}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Id}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<StackPanel Spacing="8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Title}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Id}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding Description}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding Bill.Count}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding Description}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding Bill.Count}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
<Button Content="Ouvrir"
HorizontalAlignment="Right"
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>

View file

@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -14,4 +15,19 @@ public partial class EstimateListPage : ContentPage
{
AvaloniaXamlLoader.Load(this);
}
// Re-fetch the list when returning to it (e.g. after validating an
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
// fires on the page popped back to with NavigationType.Pop; the list
// VM is retained on the nav stack while away, so this refresh picks
// up the server-side filter change that removed the signed estimate.
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (e.NavigationType == NavigationType.Pop
&& DataContext is EstimateListPageViewModel vm)
{
_ = vm.RefreshAsync();
}
}
}

View file

@ -0,0 +1,146 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateValidationPage"
x:DataType="vm:EstimateValidationPageViewModel"
Header="Validation du devis">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,8">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding RoleLabel}" Opacity="0.75" />
<TextBlock Text="{Binding ValidationStateText}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="N°"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding EstimateId}" />
</Grid>
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="Type"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding CommandType}" />
</Grid>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
Opacity="0.85" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Lignes du devis" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Bill}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dto:EstimateLineDto">
<Grid ColumnDefinitions="*,8,60,8,120" Margin="0,0,0,4">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
TextWrapping="Wrap" />
<TextBlock Grid.Column="2"
Text="{Binding Count}"
HorizontalAlignment="Right" />
<StackPanel Grid.Column="4"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="4">
<TextBlock Text="{Binding UnitaryCost, StringFormat='{}{0:N2}'}" />
<TextBlock Text="{Binding Currency}" Opacity="0.7" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
<TextBlock Text="{Binding ProSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ProPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
<TextBlock Text="{Binding ClientSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ClientPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<TextBlock FontSize="11"
Foreground="DarkSlateGray">
<Run Text="Votre signature : traits " />
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
<Run Text=" · points " />
<Run Text="{Binding PointCount, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<Button Content="Effacer"
Command="{Binding ClearSignatureCommand}" />
<Button Content="Valider"
Command="{Binding ValidateCommand}" />
<Button Content="Refuser"
Command="{Binding RejectCommand}" />
<Button Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
<Button Content="Retour"
Command="{Binding BackCommand}" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<ProgressBar Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
<postitControls:StatusBar DataContext="{Binding ActionStatus}" />
</StackPanel>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,32 @@
using Avalonia.Controls;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class EstimateValidationPage : ContentPage
{
private EstimateValidationPageViewModel? _vm;
public EstimateValidationPage()
{
InitializeComponent();
DataContextChanged += (_, _) => RebindViewModel(DataContext as EstimateValidationPageViewModel);
Unloaded += (_, _) => _vm?.Detach();
}
private void RebindViewModel(EstimateValidationPageViewModel? vm)
{
if (_vm is not null)
{
_vm.Detach();
}
_vm = vm;
if (_vm is null || ProPad is null || ClientPad is null) return;
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
}
}

View file

@ -26,11 +26,11 @@
Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis à valider"
<Button Content="Devis en attente de signature client"
Command="{Binding OpenClientEstimates}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis en attente"
<Button Content="Devis en attente de signature fournisseur"
Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>

View file

@ -29,29 +29,15 @@
FontWeight="Bold" />
<!--
Capture surface. The SignaturePadControl is render-
agnostic, so we host it inside a fixed-size Border
that is the PART_CaptureArea, and overlay a Canvas
for the visual feedback. The view's code-behind
repaints the canvas on every RedrawRequested.
Capture surface. SignaturePadView owns the SignaturePadControl,
the capture-area Border, the ink overlay canvas, and the
repaint logic; the page just sizes it from the VM and reads
the captured data through the view's API.
-->
<Border Grid.Row="1"
x:Name="PadFrame"
Width="{Binding Width}"
Height="{Binding Height}"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="Pad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<controls:SignaturePadView Grid.Row="1"
x:Name="Pad"
CaptureWidth="{Binding Width}"
CaptureHeight="{Binding Height}" />
<StackPanel Grid.Row="2"
Orientation="Horizontal"

View file

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls;
using PostIt.ViewModels;
@ -11,26 +6,15 @@ namespace PostIt.Views;
public partial class SignaturePage : ContentPage
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
private SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage()
{
InitializeComponent();
// Wire the capture area: the Pad itself is the control, the
// surrounding Border (PadFrame) is the hit-test region. We
// set CaptureArea once the control's template has been
// applied — for an inline control with no template, that
// happens on first measure, which is guaranteed before
// the user can interact, so attaching here is safe.
_control = Pad;
_control.CaptureArea = PadFrame;
// SignaturePadView owns the capture area wiring and the ink
// repaint; the page only forwards the view to the VM so it can
// read snapshots / subscribe to stroke events.
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
}
@ -39,82 +23,12 @@ public partial class SignaturePage : ContentPage
if (_vm is not null)
{
_vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
}
_vm = vm;
if (_vm is null || _control is null) return;
if (_vm is null || Pad is null) return;
_vm.Attach(_control);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
_vm.Attach(Pad.SignaturePad);
}
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
private void Repaint()
{
if (_control is null || InkLayer is null) return;
InkLayer.Children.Clear();
var w = PadFrame.Bounds.Width;
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}
}

View file

@ -12,4 +12,4 @@ public enum SignatureType
{
Pro = 0,
Client = 1,
}
}

View file

@ -5,15 +5,28 @@ namespace Yavsc
/// should be associated to any
/// client user query to a provider user or
/// other external entity.
///
/// <para>
/// Acceptance is split by party: <see cref="ProAccepted"/> when the
/// provider accepts the client's request (and produces/submits the
/// estimate), <see cref="ClientAccepted"/> when the client accepts
/// the provider's estimate. <see cref="Accepted"/> is the legacy
/// single-value kept for rows written before the split; new writes
/// use the party-specific values. Its integer value (2) is stable
/// so existing rows still deserialize.
/// </para>
/// </summary>
public enum QueryStatus: int
{
Inserted,
Rejected,
Accepted,
InProgress,
Inserted = 0,
Rejected = 1,
Accepted = 2,
InProgress = 3,
// final states
Failed,
Success
Failed = 4,
Success = 5,
// party-specific acceptance (see summary)
ProAccepted = 6,
ClientAccepted = 7,
}
}
}

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
</Project>

View file

@ -39,6 +39,39 @@ public sealed class EstimateDto
public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; }
/// <summary>
/// The provider's signature on this estimate, or null when the
/// provider has not signed. Carries the PostIt wire-format
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
/// repaint a previously drawn signature on reopen without a second
/// round-trip.
/// </summary>
public EstimateSignatureDto? SignaturePro { get; set; }
/// <summary>
/// The client's signature on this estimate, or null when the
/// client has not signed.
/// </summary>
public EstimateSignatureDto? SignatureClient { get; set; }
}
/// <summary>
/// One stored signature on an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
/// was captured and are loaded back into the pad unchanged. The side
/// (Pro/Client) is implicit in which property of
/// <see cref="EstimateDto"/> carries this DTO, so no <c>Type</c>
/// field is needed.
/// </summary>
public sealed class EstimateSignatureDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public int CoordinateMax { get; set; }
public DateTime CapturedAtUtc { get; set; }
public int[]? Strokes { get; set; }
}
/// <summary>

View file

@ -0,0 +1,56 @@
using System;
using Yavsc.Models.Billing;
namespace Yavsc.Api.Client;
/// <summary>
/// Body of <c>POST api/v1/front/query/accept</c> and
/// <c>.../query/reject</c>, mirroring the server-side
/// <c>Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest</c>.
/// <see cref="Strokes"/> is optional: when non-empty on accept it is
/// the PostIt wire-format signature captured with the acceptance; null
/// or empty means no signature (the only valid value on reject).
/// </summary>
public sealed class QueryAcceptanceRequestDto
{
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
/// <summary>
/// Which signature is being submitted:
/// <see cref="SignatureType.Pro"/> (the provider) or
/// <see cref="SignatureType.Client"/>. The PostIt client sets this
/// from the perspective the page was opened in (provider signs Pro,
/// client signs Client), so the server stores the signature under
/// the right author even when one user is both parties. Null lets
/// the server infer the role from the caller's identity.
/// </summary>
public SignatureType? SignatureType { get; set; }
}
/// <summary>
/// Response of <c>POST .../query/accept</c> / <c>.../query/reject</c>
/// (<c>Ok(new { queryId, status, signature = ... })</c>). The
/// <see cref="Signature"/> ref is non-null only when a signature was
/// staged on accept.
/// </summary>
public sealed class QueryAcceptanceResponseDto
{
public long QueryId { get; set; }
public string? Status { get; set; }
public QuerySignatureRefDto? Signature { get; set; }
}
public sealed class QuerySignatureRefDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public string? Type { get; set; }
}

View file

@ -0,0 +1,135 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the front-office query routes
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
/// to <see cref="IYavscApiClient"/>.
///
/// The server resolves the caller's role (provider vs client vs admin)
/// from the authenticated user compared to the query's
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
/// role. <paramref name="billingCode"/> is the estimate's
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
/// is its <see cref="EstimateDto.CommandId"/>.
/// </summary>
public sealed class FrontOfficeApiClient
{
private const string PathPrefix = "front";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Accept (validate) the query <paramref name="queryId"/> under
/// billing code <paramref name="billingCode"/>. When
/// <paramref name="body"/> carries a non-empty signature, the
/// server stages it on the query's linked estimate and stamps the
/// matching validation date.
/// </summary>
public async Task<QueryAcceptanceResponseDto> AcceptQueryAsync(
string billingCode,
long queryId,
QueryAcceptanceRequestDto body,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
if (body is null)
throw new ArgumentNullException(nameof(body));
var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: body,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Reject the query <paramref name="queryId"/> under billing code
/// <paramref name="billingCode"/>. A signature is not meaningful on
/// a rejection; the server refuses one with 400, so this sends no
/// body.
/// </summary>
public async Task<QueryAcceptanceResponseDto> RejectQueryAsync(
string billingCode,
long queryId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: null,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a LaTeX (<c>.tex</c>) source document
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
/// the raw TeX bytes (UTF-8 text).
/// </summary>
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a compiled PDF document
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
/// a non-2xx response throws <see cref="HttpRequestException"/>.
/// </summary>
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -73,4 +73,23 @@ public interface IYavscApiClient : IAsyncDisposable
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default);
/// <summary>
/// Download a binary resource (PDF, TeX, image, …). Unlike
/// <see cref="CallAsync{T}(HttpMethod,string,object?,CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. The same Bearer-token attach + 401-refresh-retry-once
/// contract applies.
/// </summary>
/// <remarks>
/// A default interface implementation is provided so that test
/// fakes that do not exercise binary download still compile without
/// declaring the member; the real <see cref="YavscApiClient"/>
/// overrides it with the authenticated transport.
/// </remarks>
Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
}

View file

@ -17,8 +17,8 @@
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -284,11 +284,14 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
{
_fixture.ResetAndSeedActivityGraph();
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
// Not yet provider-signed: visible in the provider's "awaiting
// provider signature" list.
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
// Provider already signed: must leave the list immediately.
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
using var http = NewClient("alice");
@ -300,7 +303,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
Assert.Equal(pendingId, item.GetProperty("id").GetInt64());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
Assert.Single(item.GetProperty("bill").EnumerateArray());
}

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using Yavsc.Helpers;
using Yavsc.Models.Billing;
using Yavsc.ViewModels.Gen;
namespace Yavsc.Api.Test;
// Unit test of the estimate PDF generation pipeline (no HTTP fixture
// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing
// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf
// — the lualatex stdin compile that writes <name>.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<int>());
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;
}
}
}

View file

@ -1,6 +1,8 @@
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;
@ -43,19 +45,26 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
private static HttpContent NoSignature() => JsonBody(new { });
private static HttpContent WithSignature() =>
JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
private static HttpContent WithSignature(SignatureType? type = null) =>
type is null
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
private static JsonContent JsonBody(object value) =>
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
/// <summary>
/// Seeds an Rdv query with distinct parties (provider alice,
/// client bob) and, when <paramref name="withEstimate"/> is true,
/// an estimate linked to it via CommandId. Returns the query id
/// and (when seeded) the estimate id.
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
/// true, an estimate linked to it via CommandId. The provider is
/// <paramref name="performer"/> (default alice) and the client is
/// <paramref name="client"/> (default bob) — pass the same value
/// for both to model a user who is both parties. Returns the query
/// id and (when seeded) the estimate id.
/// </summary>
private (long queryId, long estimateId) SeedQuery(bool withEstimate)
private (long queryId, long estimateId) SeedQuery(
bool withEstimate,
string performer = "alice",
string client = "bob")
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
@ -68,11 +77,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
ClientId = client,
PerformerId = performer,
Consent = true,
UserCreated = "alice",
UserModified = "alice",
UserCreated = performer,
UserModified = performer,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
@ -89,8 +98,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var estimate = new Estimate
{
CommandId = query.Id,
ClientId = "bob",
OwnerId = "alice",
ClientId = client,
OwnerId = performer,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
@ -127,7 +136,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
Assert.Equal(QueryStatus.Accepted, updated.Status);
// Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
}
[Fact]
@ -146,7 +156,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ProviderValidationDate != default);
@ -173,7 +183,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
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);
@ -243,4 +253,90 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Estimate_payload_carries_existing_signature_strokes()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
// Provider accepts with a signature.
using (var pro = NewClient(subject: "alice"))
{
var r = await pro.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.True(r.IsSuccessStatusCode, await r.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
// The estimate read endpoint carries the signature strokes on
// the estimate payload, so PostIt can repaint them on reopen.
using var reader = NewClient(subject: "alice");
var response = await reader.GetAsync(
$"/api/v1/estimate/{estimateId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(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<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default);
Assert.True(estimate.ProviderValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("alice", signature.SignerId);
}
// A provider may not sign as the client (and vice versa): declaring
// the other party's side is refused even though the caller is a
// party to the query. Write is authorized only for the signature's
// author.
[Fact]
public async Task Accept_declaring_other_party_side_is_forbidden()
{
var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob
using var http = NewClient(subject: "alice"); // alice is the provider
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(SignatureType.Client), // alice tries to sign as the client
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -8,8 +8,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
@ -30,4 +30,4 @@
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
</Project>

View file

@ -157,6 +157,8 @@ 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<NominativeServiceCommand>()
.ToList()
@ -169,6 +171,8 @@ 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<NominativeServiceCommand>()
.ToList()
@ -181,6 +185,8 @@ 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<NominativeServiceCommand>()
.ToList()
@ -329,9 +335,9 @@ namespace Yavsc.ApiControllers
return new ChallengeResult();
}
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename);
}

View file

@ -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).Where(e => e.OwnerId == ownerId));
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).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).Single(m => m.Id == id);
Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id);
if (estimate == null)
{
@ -209,21 +209,23 @@ namespace Yavsc.Controllers
public IActionResult GetOngoingEstimatesAsClient()
{
var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill)
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.ClientId == uid
&& e.ProviderValidationDate != default
&& e.ClientValidationDate == default));
}
// GET: api/estimate/asprovider
// Ongoing estimates (not yet validated by the client) established
// by the current user as the provider.
// Estimates awaiting the provider's signature, established by the
// current user as the provider. Once the provider validates
// (ProviderValidationDate stamped), the estimate leaves this list
// and appears in the client's "awaiting client signature" list.
[HttpGet("asprovider")]
public IActionResult GetOngoingEstimatesAsProvider()
{
var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill)
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.OwnerId == uid && e.ProviderValidationDate == default));
}
protected override void Dispose(bool disposing)

View file

@ -1,6 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Workflow;
using Yavsc.Helpers;
using Yavsc.Models;
@ -8,6 +11,7 @@ using Yavsc.Models.Billing;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
using Yavsc.ViewModels.Gen;
namespace Yavsc.ApiControllers
{
@ -18,15 +22,36 @@ namespace Yavsc.ApiControllers
ApplicationDbContext dbContext;
private readonly IBillingService billing;
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
private readonly 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> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{
dbContext = context;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
this.viewEngine = viewEngine;
}
[HttpGet("profiles/{actCode}")]
@ -35,6 +60,126 @@ namespace Yavsc.ApiControllers
return await dbContext.ListPerformersAsync(billing, actCode);
}
// GET: api/front/query/{queryId}/estimate.tex
//
// Renders the estimate linked to the query (Estimate.CommandId ==
// queryId) as a LaTeX source document. The view is a generation
// template (Layout = "null"), not a web page: it is returned with
// a text/x-tex content type so the caller can save it as a .tex
// file. Either party (client or provider) or an admin may read it.
[HttpGet("query/{queryId}/estimate.tex")]
public async Task<IActionResult> EstimateTex(long queryId, CancellationToken token)
{
if (viewEngine is null || siteSettings is null)
return Problem("TeX rendering is not configured on this host.",
statusCode: StatusCodes.Status503ServiceUnavailable);
var estimate = await LoadEstimateForRenderAsync(queryId, token);
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName)
&& uid != estimate.ClientId && uid != estimate.OwnerId)
return Forbid();
PrepareEstimateViewBag(estimate);
Response.ContentType = "text/x-tex";
return View("Estimate_tex", estimate);
}
// GET: api/front/query/{queryId}/estimate.pdf
//
// Renders the same LaTeX template to a string, compiles it to PDF
// via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams
// the generated PDF bytes back. Requires /usr/bin/texi2pdf on the
// server host; on hosts without it the endpoint returns a problem.
[HttpGet("query/{queryId}/estimate.pdf")]
public async Task<IActionResult> EstimatePdf(long queryId, CancellationToken token)
{
if (viewEngine is null || siteSettings is null)
{
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<Estimate?> 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
@ -57,6 +202,15 @@ namespace Yavsc.ApiControllers
if (query is null) return BadRequest(new { Error = "query not found" });
Signature? signature = null;
// actingRole is the role the caller is signing / accepting
// as. It defaults to the role inferred from the caller's
// identity, but when a signature is submitted the caller
// declares the side (Pro/Client) and the server honors it
// only if the caller is that party. This disambiguates a
// user who is both parties (where inference would always
// pick the provider) and makes "write only for its author"
// explicit.
ActorRole actingRole = role;
if (HasSignature(body))
{
// Admins bypass authorization but are neither party of
@ -71,20 +225,37 @@ namespace Yavsc.ApiControllers
if (estimate is null)
return BadRequest(new { Error = "no estimate linked to this query" });
var type = role == ActorRole.Provider
var uid = User.GetUserId();
bool isProvider = query.PerformerId == uid;
bool isClient = query.ClientId == uid;
// The caller declares which side they sign as. Null
// falls back to the role inferred from identity.
var declared = body!.SignatureType;
if (declared.HasValue)
{
if (declared.Value == SignatureType.Pro && !isProvider)
return Forbid();
if (declared.Value == SignatureType.Client && !isClient)
return Forbid();
actingRole = declared.Value == SignatureType.Pro
? ActorRole.Provider
: ActorRole.Client;
}
var type = actingRole == ActorRole.Provider
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body!.CoordinateMax,
CoordinateMax = body.CoordinateMax,
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
Strokes = body.Strokes!,
};
try
{
var uid = User.GetUserId();
signature = await EstimateSignaturePersister.StageAsync(
dbContext, estimate.Id, type, uid, payload, token);
}
@ -94,13 +265,22 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
}
if (role == ActorRole.Provider)
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
estimate.ClientValidationDate = DateTime.UtcNow;
}
query.Status = QueryStatus.Accepted;
// 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,
};
try
{
@ -190,28 +370,3 @@ namespace Yavsc.ApiControllers
internal enum ActorRole { None, Provider, Client, Admin }
}
namespace Yavsc.ViewModels.FrontOffice
{
/// <summary>
/// Body of <c>POST /api/v1/front/query/accept</c> and
/// <c>.../reject</c>. The <see cref="Strokes"/> 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 <c>{}</c> to accept/reject without a
/// signature.
/// </summary>
public class QueryAcceptanceRequest
{
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
/// no signature.
/// </summary>
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
}
}

View file

@ -0,0 +1,325 @@
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));
}
/// <summary>
/// Renders a captured signature as TikZ <c>\draw</c>/<c>\fill</c>
/// paths, suitable for embedding in a <c>tikzpicture</c> at the
/// foot of a devis. The signature is the PostIt wire format — a
/// length-prefixed sequence of strokes, each stroke being
/// <c>[k, x0, y0, x1, y1, …]</c> with coordinates normalised to
/// <c>[0, CoordinateMax]</c> and y growing downward (screen
/// orientation). We denormalise to a <paramref name="widthCm"/> ×
/// <paramref name="heightCm"/> 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
/// <c>Polyline</c> the Avalonia pad draws for it.
/// </summary>
/// <param name="sig">The persisted signature. Null/empty yields
/// <see cref="HtmlString.Empty"/>, so the caller can gate the
/// <c>tikzpicture</c> on a non-empty result.</param>
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 (<cwd>/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 <name>.pdf straight into the bills
// directory. lualatex is a file-based engine — it cannot emit
// the PDF on stdout — so the pdf lands at its final destination
// and we only clean the transient aux/log artifacts beside it.
//
// 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
// <name>.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();
}
}
}
}

View file

@ -55,7 +55,11 @@ internal class Program
});
})
.AddYavscCors(builder.Configuration)
.AddControllers();
.AddControllersWithViews();
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
// the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
// accepts any access token issued by identity server
services.AddAuthentication("Bearer")

View file

@ -0,0 +1,40 @@
using Yavsc.Models.Billing;
namespace Yavsc.ViewModels.FrontOffice
{
/// <summary>
/// Body of <c>POST /api/v1/front/query/accept</c> and
/// <c>.../reject</c>. The <see cref="Strokes"/> 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 <c>{}</c> to accept/reject without a
/// signature.
/// </summary>
public class QueryAcceptanceRequest
{
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
/// no signature.
/// </summary>
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
/// <summary>
/// Which signature is being submitted:
/// <see cref="SignatureType.Pro"/> (the provider) or
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
/// is present this declares the side the caller is signing as;
/// the server honors it only if the caller is that party
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
/// for Client). This disambiguates the case where one user is
/// both parties, and makes "write only for its author"
/// explicit. When null, the server falls back to the role
/// inferred from the caller's identity.
/// </summary>
public SignatureType? SignatureType { get; set; }
}
}

View file

@ -5,7 +5,7 @@ namespace Yavsc.ViewModels.Gen
{
public class PdfGenerationViewModel
{
[Required]
public string TeXSource { get; set; }
[Required]

View file

@ -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;

View file

@ -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,7 +11,6 @@
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}
@ -25,6 +24,7 @@
\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) {<text>@PostalAddress</text>}
@if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {<text>@TeXHelpers.ToTeX(to.PhoneNumber)<text>\\</text>
</text>}
</text>}
E-mail: @TeXHelpers.ToTeX(to.Email)
}
@ -105,7 +105,7 @@
\renewcommand{\headrulewidth}{0pt}
\cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { <text> - @proaddrm </text> } \newline
\small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { <text> - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) </text> }
\small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { <text> - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) </text> }
}
}
@ -113,10 +113,10 @@
% Logo de la société
@if (from.Avatar != null) {
<text>\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png}
<text>\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png}
</text>
} else {
<text>%\includegraphics{logo.png}
} else {
<text>%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png}
</text>
}
% 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) ) {
<text>\hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\
@bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\
</text>
}
@if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) {
@if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) {
<text>
\hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\
\hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC }
@ -188,19 +188,46 @@
}
@if (!(Model.ProviderValidationDate==null)) {
<text>
\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) {
<text>
\begin{center}
\hspace{263pt}
\includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png}
\end{center}
</text>
@{
// 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;
}
</text>
\vspace{2em}
\noindent
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du fournisseur}\\
@if (Model.ProviderValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
}
@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@proSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}\hfill
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du client}\\
@if (Model.ClientValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
}
@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@cliSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}
\end{document}

View file

@ -0,0 +1,12 @@
@using Yavsc
@using Yavsc.Helpers
@using Yavsc.Billing
@using Yavsc.Models
@using Yavsc.Models.Billing
@using Yavsc.ViewModels.Gen
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Html
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject Microsoft.Extensions.Options.IOptions<SiteSettings> SiteSettings

View file

@ -7,8 +7,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -9,8 +9,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
@ -32,4 +32,4 @@
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
</Project>

View file

@ -8,8 +8,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -11,8 +11,8 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
@ -88,4 +88,4 @@
</ItemGroup>
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" />
</Target>
</Project>
</Project>

View file

@ -8,7 +8,6 @@ namespace Yavsc.Controllers
using Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using Models;
using ViewModels.FrontOffice;
using Yavsc.Server.Helpers;
@ -21,22 +20,20 @@ namespace Yavsc.Controllers
readonly ILogger _logger;
readonly IStringLocalizer _SR;
private readonly IBillingService _billing;
private readonly SiteSettings _siteSettings;
public FrontOfficeController(ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
IBillingService billing,
ILoggerFactory loggerFactory,
IStringLocalizer<FrontOfficeController> SR,
IOptions<SiteSettings> siteSettings)
IStringLocalizer<FrontOfficeController> SR)
{
_context = context;
_userManager = userManager;
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
_SR = SR;
_billing = billing;
_siteSettings = siteSettings.Value;
}
public ActionResult Index()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -46,9 +43,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.Accepted
EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && (c.Status == QueryStatus.ProAccepted || 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.Accepted).Count(),
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && (e.Query.Status == QueryStatus.ProAccepted || e.Query.Status == QueryStatus.Accepted)).Count(),
BillToSignAsCliCount = 0,
NewPayementsCount = 0
@ -80,61 +77,5 @@ namespace Yavsc.Controllers
return View(result);
}
[Produces("text/x-tex"), Authorize, Route("estimate-{id}.tex")]
[HttpGet]
public ViewResult EstimateTex(long id)
{
var estimate = _context.Estimates.Include(x => x.Query)
.Include(x => x.Query.Client)
.Include(x => x.Query.PerformerProfile)
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
.Include(x => x.Query.PerformerProfile.Performer)
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
Response.ContentType = "text/x-tex";
return View("Estimate.tex", estimate);
}
[Authorize, Route("Estimate-{id}.pdf")]
[HttpGet]
public IActionResult EstimatePdf(long id)
{
ViewBag.TempDir = _siteSettings.TempDir;
ViewBag.BillsDir = _siteSettings.Bills;
var estimate = _context.Estimates.Include(x => x.Query)
.Include(x => x.Query.Client)
.Include(x => x.Query.PerformerProfile)
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
.Include(x => x.Query.PerformerProfile.Performer)
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
if (estimate == null)
throw new Exception("No data");
return View("Estimate.pdf", estimate);
}
[Authorize]
public IActionResult EstimateProValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult EstimateClientValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult BillValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult BillAcquitment()
{
throw new NotImplementedException();
}
}
}

View file

@ -1,198 +0,0 @@
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();
}
}
}
}

View file

@ -1,108 +0,0 @@
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<BillViewComponent> localizer;
private readonly SiteSettings siteSettings;
public BillViewComponent(ApplicationDbContext dbContext,
IStringLocalizer<BillViewComponent> localizer,
IBillingService billing,
IOptions<SiteSettings> siteSettings)
{
this.billing = billing;
this.dbContext = dbContext;
this.localizer = localizer;
this.siteSettings = siteSettings.Value;
}
public async Task<IViewComponentResult> 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);
}
}
}

View file

@ -1,21 +0,0 @@
@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) {
<div>
@(Model.BaseFileName).pdf was generated
</div>
} else {
<div class="error">
@Model.GenerationErrorMessage
<p>Something went wrong ...</p>
</div>
}

View file

@ -1,33 +0,0 @@
@model IBillable
<div class="bill">
@if (ViewBag.BillFileInfo.Exists) {
<a class="btn btn-link" href="~/api/bill/facture-@(ViewBag.BillingCode)-@(Model.Id).pdf" >La facture au format Pdf</a>
} else {
<script>
var onGenerate = function () {
var _btn = "this";
$.post('/api/bill/genpdf/@ViewBag.BillingCode/@Model.Id')
.done(function(data){
var res = JSON.parse(data);
window.location.href = '/api/bill/' + res.Generated;
_btn.disabled = "true";
})
.fail(function( jqXHR, textStatus, errorThrown ) {
$('#billgenerr').html('<em>La génération a échoué</em><p>'+textStatus+'</p>')
})
};
$(document).ready(function(){$('#btnGen').on('click',onGenerate)})
</script>
<div id="billgen">
<form action= method="POST">
<input id="btnGen" class="btn btn-primary" type="button" onclick="onGenerate" value="Générer la facture au format Pdf" />
<div id="billgenerr" class="error">
</div>
</form>
</div>
}
</div>

View file

@ -1,13 +0,0 @@
@model Estimate
<div class="estimate">
<a href="~/api/pdfbill/estimate-@(Model.Id).tex" >Export au format LaTeX</a>
<form action="~/api/pdfbill/gen/@Model.Id" method="POST">
<input type="submit" value="Générer le Pdf"/>
</form>
<a href="~/api/pdfbill/get/@(Model.Id)" >Télécharger le document généré</a>
</div>

View file

@ -1,21 +0,0 @@
@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) {
<div>
@(Model.BaseFileName).pdf was generated
</div>
} else {
<div class="error">
@Model.GenerationErrorMessage
<p>Something went wrong ...</p>
</div>
}

View file

@ -9,8 +9,8 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotMarkdown" />
@ -47,4 +47,4 @@
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -34,7 +34,7 @@ namespace Yavsc.Server.Helpers
{
var item = new FileReceivedInfo(
settings.Bills,
AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId));
AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId));
var fi = new FileInfo(item.FullName);
if (fi.Exists) item.Overridden = true;

View file

@ -8,6 +8,7 @@ namespace Yavsc.Models.Billing
{
using Models.Workflow;
using Newtonsoft.Json;
using System.Linq;
public partial class Estimate : IEstimate
{
@ -78,14 +79,39 @@ namespace Yavsc.Models.Billing
public DateTime ClientValidationDate { get; set; }
/// <summary>
/// All signatures captured against this estimate, in
/// capture order. Multiple versions per (Type, SignerId)
/// are allowed; the controller reads the most recent
/// when asked. See <see cref="Signature"/>.
/// All signatures captured against this estimate, keyed by
/// <see cref="SignatureType"/>. The persister upserts by
/// (EstimateId, Type), so there is at most one row per type.
/// This collection is the persisted shape and stays
/// <c>[JsonIgnore]</c>: the wire payload exposes signatures
/// only through the dedicated <see cref="SignaturePro"/> and
/// <see cref="SignatureClient"/> projections, since an
/// estimate has exactly these two signature slots and never
/// more. See <see cref="Signature"/>.
/// </summary>
[InverseProperty(nameof(Signature.Estimate))]
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual ICollection<Signature> Signatures { get; set; }
= new List<Signature>();
/// <summary>
/// The provider's signature on this estimate, or null when the
/// provider has not signed. Projected from
/// <see cref="Signatures"/>; not mapped (the table is
/// <see cref="Signatures"/>).
/// </summary>
[NotMapped]
public Signature? SignaturePro
=> Signatures?.FirstOrDefault(s => s.Type == SignatureType.Pro);
/// <summary>
/// The client's signature on this estimate, or null when the
/// client has not signed. Projected from
/// <see cref="Signatures"/>; not mapped.
/// </summary>
[NotMapped]
public Signature? SignatureClient
=> Signatures?.FirstOrDefault(s => s.Type == SignatureType.Client);
}
}

View file

@ -31,7 +31,7 @@ public class Signature
public long EstimateId { get; set; }
[ForeignKey(nameof(EstimateId)), JsonIgnore]
[ForeignKey(nameof(EstimateId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual Estimate Estimate { get; set; }
/// <summary>
@ -48,7 +48,7 @@ public class Signature
[Required]
public string SignerId { get; set; }
[ForeignKey(nameof(SignerId)), JsonIgnore]
[ForeignKey(nameof(SignerId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual ApplicationUser Signer { get; set; }
public SignatureType Type { get; set; }

View file

@ -9,8 +9,8 @@
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Anthropic.SDK" />

View file

@ -14,8 +14,8 @@
-->
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" />

View file

@ -7,8 +7,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Razor" />