Compare commits

..
77 changed files with 780 additions and 3027 deletions

View file

@ -1,81 +1,5 @@
# Changelog # 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 ## [1.0.8-rc15] - unstable
### Added ### Added
@ -445,8 +369,6 @@ the ACL now comes along with the article,
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [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.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.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 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -61,37 +61,6 @@ make test
### les services et l'API ### 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 ### La Prod
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. `cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.

View file

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

View file

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

View file

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

View file

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

View file

@ -12,8 +12,6 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
CLIOptionHandler.HandleArgs(args);
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes

View file

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

View file

@ -1,255 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using PostIt.Controls;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.Tests;
/// <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

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

View file

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

View file

@ -1,33 +0,0 @@
using System;
using System.Collections.Generic;
using Mono.Options;
using PostIt;
using PostIt.ViewModels;
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
List<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,21 +46,6 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value); set => SetValue(CaptureAreaProperty, value);
} }
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary> /// <summary>
/// Captured strokes in wire form. Exposed as a read-only view /// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI /// over the internal buffer. The buffer only mutates on the UI
@ -124,7 +109,6 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e) private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{ {
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea); e.Pointer.Capture(CaptureArea);
_capturing = true; _capturing = true;
@ -197,38 +181,6 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty); 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> /// <summary>
/// Defensive copy of the current buffer wrapped in a /// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the /// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -1,28 +0,0 @@
<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

@ -1,206 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Models;
namespace PostIt.Controls;
/// <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

@ -1,61 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace PostIt.Helpers;
/// <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,7 +31,6 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
@ -64,7 +63,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<ProviderOngoingRequestsPage>(); services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -76,7 +74,6 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
@ -19,10 +18,10 @@ namespace PostIt.ViewModels;
/// </summary> /// </summary>
public enum EstimateListPerspective public enum EstimateListPerspective
{ {
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary> /// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
Client, Client,
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary> /// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
Provider, Provider,
} }
@ -33,15 +32,14 @@ public enum EstimateListPerspective
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly EstimateListPerspective _perspective; private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _allEstimates = new(); private List<EstimateDto> _allEstimates = new();
public EstimateListPerspective Perspective => _perspective; public EstimateListPerspective Perspective => _perspective;
public string Title => _perspective == EstimateListPerspective.Client public string Title => _perspective == EstimateListPerspective.Client
? "Devis en attente de signature client" ? "Mes devis à valider"
: "Devis en attente de signature fournisseur"; : "Mes devis en attente";
public string Subtitle => _perspective == EstimateListPerspective.Client public string Subtitle => _perspective == EstimateListPerspective.Client
? "Point de vue client" ? "Point de vue client"
@ -85,14 +83,10 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateListPageViewModel( public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
EstimateApiClient estimateClient,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{ {
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_perspective = perspective; _perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
} }
public Task InitializeAsync() => RefreshAsync(); public Task InitializeAsync() => RefreshAsync();
@ -159,25 +153,4 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
private static bool ContainsInsensitive(string? value, string query) private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value) => !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase); && 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

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

View file

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

View file

@ -259,13 +259,25 @@ public partial class Settings : ViewModelBase
return; return;
} }
} }
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
var app = App.Current as App; && !string.IsNullOrWhiteSpace(envJson))
if (app!=null)
if (app.CliConfigFileSpecification!=null)
{ {
SettingsFileFullName = app.CliConfigFileSpecification; Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
FileInfo configByEnvFileInfo = new FileInfo(envJson);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
string json = File.ReadAllText(configByEnvFileInfo.FullName);
ApplyJson(json, "POSTIT_SETTINGS_JSON");
SettingsFileFullName = configByEnvFileInfo.FullName;
Loaded = true;
return;
} }
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
if (SettingsFileFullName is not null) if (SettingsFileFullName is not null)
{ {
@ -275,18 +287,10 @@ public partial class Settings : ViewModelBase
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
&& !string.IsNullOrWhiteSpace(envPath)) && !string.IsNullOrWhiteSpace(envPath))
{ {
FileInfo configByEnvFileInfo = new FileInfo(envPath); SettingsFileFullName = envPath;
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
SettingsFileFullName = configByEnvFileInfo.FullName;
} }
else else
{ {
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json"); SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
} }
@ -532,7 +536,6 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,146 +0,0 @@
<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

@ -1,32 +0,0 @@
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}" Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Devis en attente de signature client" <Button Content="Mes devis à valider"
Command="{Binding OpenClientEstimates}" Command="{Binding OpenClientEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Devis en attente de signature fournisseur" <Button Content="Mes devis en attente"
Command="{Binding OpenProviderEstimates}" Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>

View file

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

View file

@ -1,4 +1,9 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls; using PostIt.Controls;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -6,15 +11,26 @@ namespace PostIt.Views;
public partial class SignaturePage : ContentPage 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 SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage() public SignaturePage()
{ {
InitializeComponent(); InitializeComponent();
// SignaturePadView owns the capture area wiring and the ink // Wire the capture area: the Pad itself is the control, the
// repaint; the page only forwards the view to the VM so it can // surrounding Border (PadFrame) is the hit-test region. We
// read snapshots / subscribe to stroke events. // 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;
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel); DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
} }
@ -23,12 +39,82 @@ public partial class SignaturePage : ContentPage
if (_vm is not null) if (_vm is not null)
{ {
_vm.Detach(); _vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
} }
_vm = vm; _vm = vm;
if (_vm is null || Pad is null) return; if (_vm is null || _control is null) return;
_vm.Attach(Pad.SignaturePad); _vm.Attach(_control);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
} }
}
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

@ -5,28 +5,15 @@ namespace Yavsc
/// should be associated to any /// should be associated to any
/// client user query to a provider user or /// client user query to a provider user or
/// other external entity. /// 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> /// </summary>
public enum QueryStatus: int public enum QueryStatus: int
{ {
Inserted = 0, Inserted,
Rejected = 1, Rejected,
Accepted = 2, Accepted,
InProgress = 3, InProgress,
// final states // final states
Failed = 4, Failed,
Success = 5, Success
// party-specific acceptance (see summary)
ProAccepted = 6,
ClientAccepted = 7,
} }
} }

View file

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

View file

@ -39,39 +39,6 @@ public sealed class EstimateDto
public string CommandType { get; set; } = string.Empty; public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; } public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { 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> /// <summary>

View file

@ -1,56 +0,0 @@
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

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

View file

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

View file

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

View file

@ -1,191 +0,0 @@
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,8 +1,6 @@
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures; using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers; using Yavsc.Helpers;
@ -45,26 +43,19 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
private static HttpContent NoSignature() => JsonBody(new { }); private static HttpContent NoSignature() => JsonBody(new { });
private static HttpContent WithSignature(SignatureType? type = null) => private static HttpContent WithSignature() =>
type is null JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
private static JsonContent JsonBody(object value) => private static JsonContent JsonBody(object value) =>
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json")); JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
/// <summary> /// <summary>
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is /// Seeds an Rdv query with distinct parties (provider alice,
/// true, an estimate linked to it via CommandId. The provider is /// client bob) and, when <paramref name="withEstimate"/> is true,
/// <paramref name="performer"/> (default alice) and the client is /// an estimate linked to it via CommandId. Returns the query id
/// <paramref name="client"/> (default bob) — pass the same value /// and (when seeded) the estimate id.
/// for both to model a user who is both parties. Returns the query
/// id and (when seeded) the estimate id.
/// </summary> /// </summary>
private (long queryId, long estimateId) SeedQuery( private (long queryId, long estimateId) SeedQuery(bool withEstimate)
bool withEstimate,
string performer = "alice",
string client = "bob")
{ {
WorkflowHelpers.ConfigureBillingService(); WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph(); _fixture.ResetAndSeedActivityGraph();
@ -77,11 +68,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var query = new RdvQuery var query = new RdvQuery
{ {
ActivityCode = "dev", ActivityCode = "dev",
ClientId = client, ClientId = "bob",
PerformerId = performer, PerformerId = "alice",
Consent = true, Consent = true,
UserCreated = performer, UserCreated = "alice",
UserModified = performer, UserModified = "alice",
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1), EventDate = DateTime.UtcNow.AddDays(1),
@ -98,8 +89,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var estimate = new Estimate var estimate = new Estimate
{ {
CommandId = query.Id, CommandId = query.Id,
ClientId = client, ClientId = "bob",
OwnerId = performer, OwnerId = "alice",
CommandType = BillingCodes.Rdv, CommandType = BillingCodes.Rdv,
Title = "Devis prestation", Title = "Devis prestation",
Description = "Devis de test", Description = "Devis de test",
@ -136,8 +127,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId); var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
// Default caller is "alice" (the provider) → ProAccepted. Assert.Equal(QueryStatus.Accepted, updated.Status);
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
} }
[Fact] [Fact]
@ -156,7 +146,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
using var assertScope = _fixture.Services.CreateScope(); using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ProviderValidationDate != default); Assert.True(estimate.ProviderValidationDate != default);
@ -183,7 +173,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
using var assertScope = _fixture.Services.CreateScope(); using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default); Assert.True(estimate.ClientValidationDate != default);
@ -253,90 +243,4 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 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> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
@ -30,4 +30,4 @@
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -157,8 +157,6 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -171,8 +169,6 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -185,8 +181,6 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -335,9 +329,9 @@ namespace Yavsc.ApiControllers
return new ChallengeResult(); return new ChallengeResult();
} }
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id); var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename)); FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" }); if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); 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") ; else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ;
// or just do nothing // or just do nothing
return new StatusCodeResult(StatusCodes.Status403Forbidden); return new StatusCodeResult(StatusCodes.Status403Forbidden);
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId)); return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId));
} }
// GET: api/Estimate/5 // GET: api/Estimate/5
[HttpGet("{id}", Name = "GetEstimate")] [HttpGet("{id}", Name = "GetEstimate")]
@ -51,7 +51,7 @@ namespace Yavsc.Controllers
return BadRequest(ModelState); return BadRequest(ModelState);
} }
Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id); Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id);
if (estimate == null) if (estimate == null)
{ {
@ -209,23 +209,21 @@ namespace Yavsc.Controllers
public IActionResult GetOngoingEstimatesAsClient() public IActionResult GetOngoingEstimatesAsClient()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) return Ok(_context.Estimates.Include(e => e.Bill)
.Where(e => e.ClientId == uid .Where(e => e.ClientId == uid
&& e.ProviderValidationDate != default && e.ProviderValidationDate != default
&& e.ClientValidationDate == default)); && e.ClientValidationDate == default));
} }
// GET: api/estimate/asprovider // GET: api/estimate/asprovider
// Estimates awaiting the provider's signature, established by the // Ongoing estimates (not yet validated by the client) established
// current user as the provider. Once the provider validates // by the current user as the provider.
// (ProviderValidationDate stamped), the estimate leaves this list
// and appears in the client's "awaiting client signature" list.
[HttpGet("asprovider")] [HttpGet("asprovider")]
public IActionResult GetOngoingEstimatesAsProvider() public IActionResult GetOngoingEstimatesAsProvider()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) return Ok(_context.Estimates.Include(e => e.Bill)
.Where(e => e.OwnerId == uid && e.ProviderValidationDate == default)); .Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View file

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

@ -1,325 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Yavsc.Models.Billing;
namespace Yavsc.Helpers
{
using Microsoft.AspNetCore.Mvc.Razor;
using ViewModels.Gen;
public class TeXString : HtmlString
{
public TeXString(TeXString teXString): base(teXString.ToString())
{
}
public TeXString(string str) : base(str)
{
}
public static TeXString operator+ (TeXString a, TeXString b) {
return new TeXString(a.ToString()+b.ToString());
}
}
public class Replacement
{
string target;
string replacement;
public Replacement(string target, string replacement)
{
this.target = target;
this.replacement = replacement;
}
public string Execute(string source)
{
return source?.Replace(target, replacement) ?? null;
}
}
public static class TeXHelpers
{
public static readonly Replacement[] SpecialCharsDefaultRendering =
{
new Replacement("<","\\textless"),
new Replacement(">","\\textgreater"),
new Replacement("©","\\copyright"),
new Replacement("®","\\textregistered"),
new Replacement("\\","\\textbackslash"),
new Replacement("™","\\texttrademark"),
new Replacement("¶","\\P"),
new Replacement("|","\\textbar"),
new Replacement("%","\\%"),
new Replacement("{","\\{"),
new Replacement("}","\\}"),
new Replacement("_","\\_"),
new Replacement("#","\\#"),
new Replacement("$","\\$"),
new Replacement("_","\\_"),
new Replacement("¿","\\textquestiondown"),
new Replacement("§","\\S"),
new Replacement("£","\\pounds"),
new Replacement("&","\\&"),
new Replacement("¡","\\textexclamdown"),
new Replacement("†","\\dag"),
new Replacement("–","\\textendash"),
new Replacement("°","\\textdegree")
};
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
return new TeXString(result);
}
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
result = result.Replace("\n","\\tabularnewline ");
return new TeXString(result);
}
public static string NewLinesWith(this string target, string separator)
{
var items = target.Split(new char[] { '\n' }).Where(
s => !string.IsNullOrWhiteSpace(s));
return string.Join(separator, items);
}
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
{
if (source == null) return new TeXString(defaultValue);
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
}
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
{
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
var alines = source.Split(',');
var texlines = alines.Select(l=>l.ToTeX().ToString());
return new TeXString(string.Join(lineSeparator,texlines));
}
/// <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,11 +55,7 @@ internal class Program
}); });
}) })
.AddYavscCors(builder.Configuration) .AddYavscCors(builder.Configuration)
.AddControllersWithViews(); .AddControllers();
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
// the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
// accepts any access token issued by identity server // accepts any access token issued by identity server
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")

View file

@ -1,40 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,198 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
namespace Yavsc.Helpers
{
using Microsoft.AspNetCore.Mvc.Infrastructure;
using ViewModels.Gen;
public class TeXString : HtmlString
{
public TeXString(TeXString teXString): base(teXString.ToString())
{
}
public TeXString(string str) : base(str)
{
}
public static TeXString operator+ (TeXString a, TeXString b) {
return new TeXString(a.ToString()+b.ToString());
}
}
public class Replacement
{
string target;
string replacement;
public Replacement(string target, string replacement)
{
this.target = target;
this.replacement = replacement;
}
public string Execute(string source)
{
return source?.Replace(target, replacement) ?? null;
}
}
public static class TeXHelpers
{
public static readonly Replacement[] SpecialCharsDefaultRendering =
{
new Replacement("<","\\textless"),
new Replacement(">","\\textgreater"),
new Replacement("©","\\copyright"),
new Replacement("®","\\textregistered"),
new Replacement("\\","\\textbackslash"),
new Replacement("™","\\texttrademark"),
new Replacement("¶","\\P"),
new Replacement("|","\\textbar"),
new Replacement("%","\\%"),
new Replacement("{","\\{"),
new Replacement("}","\\}"),
new Replacement("_","\\_"),
new Replacement("#","\\#"),
new Replacement("$","\\$"),
new Replacement("_","\\_"),
new Replacement("¿","\\textquestiondown"),
new Replacement("§","\\S"),
new Replacement("£","\\pounds"),
new Replacement("&","\\&"),
new Replacement("¡","\\textexclamdown"),
new Replacement("†","\\dag"),
new Replacement("–","\\textendash"),
new Replacement("°","\\textdegree")
};
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
return new TeXString(result);
}
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
result = result.Replace("\n","\\tabularnewline ");
return new TeXString(result);
}
public static string NewLinesWith(this string target, string separator)
{
var items = target.Split(new char[] { '\n' }).Where(
s => !string.IsNullOrWhiteSpace(s));
return string.Join(separator, items);
}
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
{
if (source == null) return new TeXString(defaultValue);
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
}
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
{
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
var alines = source.Split(',');
var texlines = alines.Select(l=>l.ToTeX().ToString());
return new TeXString(string.Join(lineSeparator,texlines));
}
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
{
string errorMsg = null;
var billdir = Model.DestDir;
var tempdir = settings.TempDir;
string name = Model.BaseFileName;
string fullname = new FileInfo(
System.IO.Path.Combine(tempdir, name)).FullName;
string ofullname = new FileInfo(
System.IO.Path.Combine(billdir, name)).FullName;
FileInfo fi = new FileInfo(fullname + ".tex");
FileInfo fo = new FileInfo(ofullname + ".pdf");
using (StreamWriter sw = new StreamWriter(fi.FullName))
{
sw.Write(Model.TeXSource);
}
if (!fi.Exists)
{
errorMsg = "Source write failed";
}
else
{
using (Process p = new Process())
{
p.StartInfo.WorkingDirectory = tempdir;
p.StartInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = tempdir,
FileName = "/usr/bin/texi2pdf",
Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}"
};
p.Start();
p.WaitForExit();
if (p.ExitCode != 0)
{
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
}
else
{
fi.Delete();
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
di.Delete(true);
}
}
}
Model.Generated = fo.Exists;
Model.GenerationErrorMessage = new HtmlString(errorMsg);
return fo.Exists;
}
public static string RenderViewToString(
this Controller controller, IViewEngine engine,
IActionContextAccessor contextAccessor,
string viewName, object model, bool isMainPage = true)
{
using (var sw = new StringWriter())
{
if (engine == null)
throw new InvalidOperationException("no engine");
// try to find the specified view
controller.TryValidateModel(model);
ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage);
// create the associated context
ViewContext viewContext = new ViewContext();
viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor;
viewContext.HttpContext = contextAccessor.ActionContext.HttpContext;
viewContext.TempData = controller.TempData;
viewContext.View = viewResult.View;
viewContext.Writer = sw;
// write the render view with the given context to the stringwriter
viewResult.View.RenderAsync(viewContext).Wait();
return sw.GetStringBuilder().ToString();
}
}
}
}

View file

@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.ViewModels;
using Yavsc.ViewModels.Gen;
using Yavsc.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Yavsc.ViewComponents
{
public class BillViewComponent : ViewComponent
{
readonly ApplicationDbContext dbContext;
readonly IBillingService billing;
readonly IStringLocalizer<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

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

View file

@ -0,0 +1,21 @@
@using System.Reflection
@using System.IO
@using Microsoft.Extensions.WebEncoders
@using System.Diagnostics
@using System.Text
@using Yavsc.Helpers
@model Yavsc.ViewModels.Gen.PdfGenerationViewModel
@{
ViewBag.Settings.GenerateEstimatePdf(Model);
}
@if (Model.Generated) {
<div>
@(Model.BaseFileName).pdf was generated
</div>
} else {
<div class="error">
@Model.GenerationErrorMessage
<p>Something went wrong ...</p>
</div>
}

View file

@ -3,7 +3,7 @@
@using System.Globalization @using System.Globalization
@model IBillable @model IBillable
@{ @{
Layout = null; Layout = "null";
var pro = ViewBag.PerformerProfile; var pro = ViewBag.PerformerProfile;
var from = ViewBag.Performer; var from = ViewBag.Performer;
var to = ViewBag.Client; var to = ViewBag.Client;

View file

@ -0,0 +1,33 @@
@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

@ -0,0 +1,13 @@
@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

@ -0,0 +1,21 @@
@using System.Reflection
@using System.IO
@using Microsoft.Extensions.WebEncoders
@using System.Diagnostics
@using System.Text
@using Yavsc.Helpers
@model Yavsc.ViewModels.Gen.PdfGenerationViewModel
@{
SiteSettings.Value.GenerateEstimatePdf(Model);
}
@if (Model.Generated) {
<div>
@(Model.BaseFileName).pdf was generated
</div>
} else {
<div class="error">
@Model.GenerationErrorMessage
<p>Something went wrong ...</p>
</div>
}

View file

@ -3,7 +3,7 @@
@using System.Globalization @using System.Globalization
@model Estimate @model Estimate
@{ @{
Layout = null; Layout = "null";
var pro = Model.Query.PerformerProfile; var pro = Model.Query.PerformerProfile;
var from = Model.Query.PerformerProfile.Performer; var from = Model.Query.PerformerProfile.Performer;
var to = Model.Query.Client; var to = Model.Query.Client;
@ -11,6 +11,7 @@
var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX();
var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address);
var isestimate = !ViewBag.AsBill; var isestimate = !ViewBag.AsBill;
var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png");
} }
\documentclass[french,11pt]{article} \documentclass[french,11pt]{article}
@ -24,7 +25,6 @@
\usepackage{graphicx} \usepackage{graphicx}
\usepackage{fancyhdr} \usepackage{fancyhdr}
\usepackage{fp} \usepackage{fp}
\usepackage{tikz}
\def\TVA{20} % Taux de la TVA \def\TVA{20} % Taux de la TVA
@ -49,7 +49,7 @@
\global\let\TotalHT\TotalHT \global\let\TotalHT\TotalHT
\global\let\TotalTVA\TotalTVA \global\let\TotalTVA\TotalTVA
\global\let\TotalTTC\TotalTTC \global\let\TotalTTC\TotalTTC
\cr \cr
\hline \hline
\textbf{Total} & & & \TotalHT \textbf{Total} & & & \TotalHT
} }
@ -77,12 +77,12 @@
% Infos Client % Infos Client
\def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client \def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client
\def\ClientAdresse{ \def\ClientAdresse{
% Adresse du client % Adresse du client
@PostalAddress @PostalAddress
@if (PostalAddress!=null) {<text>@PostalAddress</text>} @if (PostalAddress!=null) {<text>@PostalAddress</text>}
@if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {<text>@TeXHelpers.ToTeX(to.PhoneNumber)<text>\\</text> @if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {<text>@TeXHelpers.ToTeX(to.PhoneNumber)<text>\\</text>
</text>} </text>}
E-mail: @TeXHelpers.ToTeX(to.Email) E-mail: @TeXHelpers.ToTeX(to.Email)
} }
@ -105,7 +105,7 @@
\renewcommand{\headrulewidth}{0pt} \renewcommand{\headrulewidth}{0pt}
\cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { <text> - @proaddrm </text> } \newline \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é % Logo de la société
@if (from.Avatar != null) { @if (from.Avatar != null) {
<text>\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png} <text>\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png}
</text> </text>
} else { } else {
<text>%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png} <text>%\includegraphics{logo.png}
</text> </text>
} }
% Nom et adresse de la société % Nom et adresse de la société
@ -170,12 +170,12 @@
\begin{center} \begin{center}
\begin{tabular}{|c c c c|} \begin{tabular}{|c c c c|}
@if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode) @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} \\ <text>\hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\
@bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\ @bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\
</text> </text>
} }
@if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) {
<text> <text>
\hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\ \hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\
\hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC } \hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC }
@ -188,46 +188,19 @@
} }
@{ @if (!(Model.ProviderValidationDate==null)) {
// Captured signatures, if any. The Estimate.Signatures collection <text>
// is loaded by the controller; SignaturePro/SignatureClient project \begin{flushright}
// the row by type. We draw them as vector TikZ at the foot of the @(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy"))
// devis (see TeXHelpers.SignatureToTikz), so there is no PNG to \end{flushright}
// produce or \includegraphics to fail on. @if (prosign.Exists) {
var fr = CultureInfo.CreateSpecificCulture("fr-FR"); <text>
var proSig = Model.SignaturePro; \begin{center}
var cliSig = Model.SignatureClient; \hspace{263pt}
\includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png}
\end{center}
</text>
} }
</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} \end{document}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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