Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
806eb05bcc Add missing Account/Delete confirmation page
The POST Delete(UnregisterViewModel) action and the GET Delete() action
both existed in AccountController, but Views/Account/Delete.cshtml was
missing — so GET /Account/Delete had no page to render. Add the
confirmation view (posts back to Delete with the user's UserId, with a
"Confirmer la suppression" button and an "Annuler" cancel link).

Add a smoke test: an authenticated GET /Account/Delete returns 200 and
the confirmation page body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:31 +01:00
eb560045b9 Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
682c0a658d make reinstall, better 2026-09-21 14:58:40 +01:00
e6ded21bce asciidoc => markdown 2026-09-21 14:39:25 +01:00
caea7463b1 Hide inserted estimates from client list; redirect anon to /signin
Estimates with status Inserted (provider hasn't worked on them yet)
were showing up in the client's "Mes devis à valider" list. The
asclient endpoint now requires ProviderValidationDate to be set, so
only estimates the provider has actually established and submitted
are visible to the client. The asprovider endpoint is unchanged
(providers still need to see incoming unprocessed requests).

Also fix the anonymous-access redirect on Yavsc.Org: the application
cookie's LoginPath defaulted to /Account/Login, which has no
controller. Anonymous hits to a restricted page now redirect to the
real /signin endpoint (with /signout and /Account/AccessDenied set
to match the existing controller routes).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:38:49 +01:00
0e98f5732c deploy in pre and prod : bug fixes 2026-09-21 12:49:38 +01:00
595c7e0ff5 Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:29:33 +01:00
15c64bbf7b Restore tree-structured comments in Blog Details
The $.psc.blogcomment widget (from the removed wwwroot/js/comment.js)
provided reply-to-comment nesting. Rewrite Details.cshtml to drive the
same /api/v1/blogcomments endpoint with ParentId, delegating reply
clicks so they work across the whole nested tree.

Render comment metadata server-side in the Comment view component
template (the widget used to add it client-side) and Include Author so
nested comments get their author instead of relying on the request-scoped
identity map.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:21:33 +01:00
c721f7bdd0 Rewrite Blogspot Index view with skip/take pagination
Renders Prev/Next pager from the Index(string id, int skip, int take)
call profile, drops dead .sametitle JS, and replaces the redundant
ReadPermission branch and Minutes>0 modifié check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
f521d6e159 The blogspot iis now in the PostIt app,
no more creation/edition from here
2026-09-21 10:40:49 +01:00
61d7f99ba0 accessibility 2026-09-21 10:38:09 +01:00
9494ac2067 reinstall-prod target 2026-09-21 10:37:39 +01:00
1b92b76fc9 code cleaup 2026-09-20 21:06:02 +01:00
4b34d67b3b reprod deployment 2026-09-20 18:35:27 +01:00
7f20ded14e --stop-on-fail 2026-09-20 14:12:08 +01:00
e80ad6ac92 test is ialready skipped when adb is not available 2026-09-20 13:42:45 +01:00
350dd1446d nuget referencezs updated 2026-09-20 01:53:20 +01:00
bc72d46bb3 Use AvaloniaEdit 2026-09-20 00:21:07 +01:00
2b2dca2a38 Publish, bug fix 2026-09-19 22:20:52 +01:00
1f849af886 refacto publication 2026-09-19 21:46:08 +01:00
6329309e68 Blog Post Id 2026-09-19 21:08:10 +01:00
dec9c6eb03 Updated 2026-09-19 18:09:02 +01:00
3632ed0ceb Org Markdown : Markdig 2026-09-19 18:00:09 +01:00
4bca36906e optimisation of workflows : Skiasharp deps already installed
All checks were successful
Forgejo Release / release (push) Successful in 5m35s
2026-09-19 16:37:57 +01:00
4b7ac2dec8 test(blogs): configure SiteSettings.Blog in MappedClaimsBlogsWebServerFixture
BlogSpotService's constructor throws InvalidOperationException
("SiteSettings.Blog is not configured.") when IOptions<SiteSettings>.Blog
is null, so every request to BlogApiController 500'd resolving the
service — all 3 BlogApiMappedClaimsTests were failing with
InternalServerError.

The mapped-claims fixture never configured SiteSettings (the main
BlogsWebServerFixture does, pointing Blog at a per-run temp dir).
Mirror that: Configure<SiteSettings>(s => s.Blog = <temp dir>)
before AddScoped<BlogSpotService>(). These tests are JSON-only
(AttachFiles short-circuits), but the service must be constructible.

Verified: BlogApiMappedClaimsTests 3/3 green; full Yavsc.Blogs.Tests
37/37 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 14:14:28 +01:00
92af3c0428 build: disable StaticWebAssets cache to stop MSB4018 race
DefineStaticWebAssets.ReadOrCreateCache opens the *.dswa.cache.json
files under obj/.../ via File.OpenRead with no try/catch, so under
parallel solution builds a sharing-violation / TOCTOU race on the cache
file escapes as MSB4018 and fails the whole build (seen on
Yavsc.Blogs during the rc15 solution build).

Set StaticWebAssetsCacheDefineStaticWebAssetsEnabled=false so
CacheManifestPath is empty and ReadOrCreateCache short-circuits to a
no-file-I/O path. The cache is a pure build-speed optimization; no
effect on output correctness, only on incremental rebuild speed of the
wwwroot-light web projects.

Verified: Yavsc.Api (transitive web deps) and Yavsc.Blogs.Tests build
clean, and zero *.dswa.cache.json files are produced.

Refs:
  https://source.dot.net/Microsoft.NET.Sdk.StaticWebAssets.Tasks/DefineStaticWebAssets.Cache.cs.html
  https://github.com/dotnet/sdk/issues/49039

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 13:53:25 +01:00
5470924cdb changelog: add 1.0.8-rc15 entry
Cover the 60 commits since 1.0.8-rc14: blog attachments + Markdown
preview, the Estimate pipeline (list/edition/validation + API + EF
migrations), provider ongoing requests, file-ACL and genericEstimate
migrations, POSTIT_SETTINGS_JSON, billing filtered by user, shared
YavscMessageSender, Gitleaks CI, and the test stabilization wave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 12:56:02 +01:00
289626bd89 preview the Markdown 2026-09-18 19:45:35 +01:00
f038c08276 +MarkdownViewer 2026-09-18 13:58:31 +01:00
98be57f798 post the file 2026-09-18 13:38:42 +01:00
64df506f3d corrige la création du lien 2026-09-18 12:13:00 +01:00
6a1bd23ca5 test: cover BlogApiClient.CreateMultipartContent shape + server round-trip
Expose CreateMultipartContent as public so the blogs backend tests can
assert on its wire shape directly and round-trip it through the live
BlogsWebServerFixture host. New CreateMultipartContentTests:
- shape: blog part is camelCase JSON with WhenWritingNull, one file
  part per attachment (filename, Content-Type, octet-stream fallback);
- round-trip: POST the built body to /api/v1/blogspot, assert 201
  Created + attachment row + file landed under BlogFilesRoot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-18 11:39:45 +01:00
74cfa10b6b -Config.Temp 2026-09-18 11:13:44 +01:00
3160eada93 refacto site settings 2026-09-17 23:54:36 +01:00
af5c1a3e92 BlogAttachmentLinkTests 2026-09-17 00:57:52 +01:00
4d17a39258 Supprime un faux message d'erreur 2026-09-16 19:48:13 +01:00
6ae814ef8a better indications 2026-09-16 18:53:17 +01:00
2787939ad7 new defaults 2026-09-16 18:23:52 +01:00
07a2986769 code cleanup 2026-09-16 18:23:42 +01:00
8bf6f20048 fixes the upload 2026-09-16 18:23:27 +01:00
eb22e4a5ab Estimate lists 2026-09-15 00:19:23 +01:00
6e92c57c1d install native dependencies 2026-09-14 10:07:10 +01:00
cdd963e8b7 fixes the pipeline 2026-09-14 00:47:28 +01:00
812a3301e7 removes the ItemGroup.Using Include=Xunit 2026-09-14 00:40:30 +01:00
c6d75f1d77 build before testing (sunit not found by MSBuild task) 2026-09-14 00:02:54 +01:00
d172f00c5b Merge remote-tracking branch 'origin/main' into feat/files-control 2026-09-13 23:39:41 +01:00
99a8707504 The Estimate is validated 2026-09-13 23:29:06 +01:00
027c0e1a03 Estimate UI 2026-09-13 18:54:27 +01:00
9550f9fe2d better and simpler UI 2026-09-13 17:51:42 +01:00
3cb98f6603 POSTIT_SETTINGS_JSON env var 2026-09-13 13:34:39 +01:00
37300206fb list only my billing codes 2026-09-13 03:49:07 +01:00
3003e6fe93 take the scanning in account 2026-09-13 03:25:09 +01:00
ee373f81dc use Sqlite 2026-09-13 03:22:22 +01:00
9a77209304 got saved a RdvQuery 2026-09-13 03:09:12 +01:00
9cae5b1f55 tests en vert 2026-09-12 17:31:18 +01:00
128735a237 fixe tests 2026-09-12 16:49:36 +01:00
9f55ef84da GetOpenIdConfiguration_returns_ok against testing host 2026-09-12 14:30:50 +01:00
80f9040ac3 Use a shared YavscMessageSender 2026-09-12 13:57:25 +01:00
bcf36709d7 Add provider ongoing requests flow and sort persistence 2026-09-12 13:29:49 +01:00
9d3e4c8b78 generic estimate 2026-09-12 12:59:44 +01:00
4b2e2a1c08 fixes the tests 2026-09-11 18:37:28 +01:00
4a28bc63f9 MainPage => BlogsPage 2026-09-11 18:37:26 +01:00
15317207c6 Bad test, fix the test 2026-09-11 18:37:25 +01:00
1e0dc07a2c Test the release, don't ignore test return code 2026-09-11 18:37:24 +01:00
69d404bc49 Bad test, fix the test 2026-09-11 13:42:29 +01:00
2cd5c6b68a Test the release, don't ignore test return code 2026-09-11 13:25:32 +01:00
2ee8b3dbec implements a dico 2026-09-11 12:45:13 +01:00
7bd169bf61 We don't need these "release" branches 2026-09-11 11:01:46 +01:00
78721a34de user the CommandBar 2026-09-07 20:53:23 +01:00
d3392f1e08 db testing 2026-09-07 20:02:56 +01:00
b356c17a2c Removes an abusive assertion, that went wrong 2026-09-07 19:16:23 +01:00
7b4538b03a Post Files 2026-09-07 15:41:33 +01:00
cf5c2f4b39 code reorg 2026-09-07 14:39:53 +01:00
17fd68b474 code format 2026-09-07 14:12:34 +01:00
82b5d06230 scan the commit 2026-09-07 13:20:50 +01:00
ed456ab3d1 Gitleaks integration 2026-09-07 12:56:48 +01:00
867d756a26 cleanup 2026-09-07 12:28:24 +01:00
f079165212 test 2026-09-07 12:20:55 +01:00
e877a59606 picket scan 2026-09-07 12:08:57 +01:00
5bd0e998a2 Secret scan 2026-09-07 11:57:00 +01:00
e2d1953faf CircleAuthorizationToFile EF migration 2026-09-07 11:02:41 +01:00
31a723f5fa unique email handling 2026-09-07 10:58:20 +01:00
92aa68c03c code reorg 2026-09-07 10:30:45 +01:00
296 changed files with 42336 additions and 4007 deletions

View file

@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=db
POSTGRES_USER=pguser
POSTGRES_PASSWORD=pgpassword
POSTGRES_PASSWORD=<your pg password>
ANTHROPIC_API_KEY=none
ANTHROPIC_MAX_TOKENS=0

View file

@ -19,20 +19,21 @@ on:
required: false
type: boolean
push:
branches: [ "main" ]
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
pull_request:
branches: [ "main", "release/*" ]
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
jobs:
build:
runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps:
- name: Clone yavsc
run: |
set -e
cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
cd _src
if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF"
@ -40,12 +41,18 @@ jobs:
fi
git submodule update --init --recursive
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Secret scan
run: |
echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \
- name: Build
run: |
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
- name: Test
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml" \
&& echo "✅ Success !" || echo "❌ Fail ($?)!"
--report-xunit \
--report-xunit-html \
--report-xunit-html-filename "test-results.html"

View file

@ -52,7 +52,7 @@ jobs:
release:
runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps:
- name: Clone du repo au tag demandé
env:
@ -69,7 +69,7 @@ jobs:
cd /src
if [[ ! -d _src/.git ]]; then
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
fi
cd _src
@ -200,6 +200,7 @@ jobs:
RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: |
set -e
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call."
exit 1

45
.gitleaksignore Normal file
View file

@ -0,0 +1,45 @@
# Exclure uniquement les dossiers de sortie de compilation
bin/
obj/
src/*/bin/
src/*/obj/
test/*/bin/
test/*/obj/
# Toolchain front (Node / esbuild)
node_modules/
build/
package-lock.json
# Exclure les caches lourds
.git/
.vs/
.env
.*.env
*.csproj.lscache
data/
appsettings.*.json
appsettings-*.*.json
# Exception: the Testing-environment override for Yavsc.Org is a tracked
# configuration source, not a secrets file. TestWebApplicationFactory
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
# AddConfiguration("org") in Program.Main loads this file as the
# last in the chain (it is optional). It overrides the connection
# string and SMTP section for the in-memory test host and contains
# no production secrets.
!src/Yavsc.Org/appsettings-org.Testing.json
generated/
*.tmp
tmp/
DataDir/
*.tests.trx
*.tests.html
*.log

27
.vscode/launch.json vendored
View file

@ -4,6 +4,22 @@
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj"
},
{
"name": "PostIt Desktop local",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Desktop/bin/Debug/net10.0/PostIt.Desktop.dll",
"env": {
"POSTIT_SETTINGS_JSON": "/home/paul/Workspace/yavsc/src/PostIt/PostIt/postit-settings.json"
},
"preLaunchTask": "dotnet: build-postit-desktop"
},
{
"name": "Android Debug",
"type": "mono",
@ -20,10 +36,10 @@
"port": 55555
},
{
"name": "API",
"name": "Yavsc API",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Api/Api.csproj"
"projectPath": "${workspaceFolder}/src/Yavsc.Api/Yavsc.Api.csproj"
},
{
"name": "Yavsc Org",
@ -37,12 +53,7 @@
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
{
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
},
{
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",

62
.vscode/tasks.json vendored
View file

@ -1,27 +1,13 @@
{
"version": "2.0.0",
"isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
}
],
"tasks": [
{
"label": "run-debug-android",
"command": "dotnet",
"type": "shell",
"problemMatcher": ["$msCompile"],
"options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": {
@ -43,11 +29,15 @@
"label": "build",
"command": "dotnet",
"type": "process",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"],
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
"isBackground": true
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "test blogs backend",
@ -63,6 +53,28 @@
"isDefault": false
}
},
{
"label": "test api backend (npgsql)",
"type": "process",
"problemMatcher": "$msCompile",
"command": "dotnet",
"args": [
"test",
"Yavsc.Api.Test.csproj",
"-v",
"minimal"
],
"options": {
"cwd": "src/Yavsc.Api.Test",
"env": {
"YAVSC_API_TEST_DB_PROVIDER": "npgsql"
}
},
"group": {
"kind": "test",
"isDefault": false
}
},
{
"label": "build-webapi",
"type": "process",
@ -76,6 +88,22 @@
"kind": "build"
},
"isBackground": true
},
{
"label": "dotnet: build-postit-desktop",
"type": "process",
"isBuildCommand": true,
"isTestCommand": false,
"isBackground": true,
"command": "dotnet",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"],
"options": {
"cwd": "src/PostIt/PostIt.Desktop"
},
"group": {
"kind": "build"
}
}
]
}

View file

@ -1,5 +1,121 @@
# Changelog
## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
### Added
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
`ClientValidationDate` selon le côté signataire.
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
pour distinguer l'acceptation de chaque partie.
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
câble `AddControllersWithViews` + `SiteSettings`).
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
dans PostIt sur la page de validation du devis et le détail de commande,
avec un helper `StorageProvider` d'enregistrement de fichier.
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
fournisseur » / « Devis en attente de signature client ».
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
* [CLI] Option `-c configuration-file.json` au lancement.
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
nettoyés) ; skip propre si `lualatex` est absent.
* [README] Dépendances système au runtime pour la génération PDF :
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
### Changed
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
« en attente ».
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
afin que seul le `.pdf` persiste.
### Fixed
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
d'entrée — stdin est alors traité comme un fichier régulier et le document
multi-lignes compile. C'était la cause racine de l'échec en production, pas
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
unique).
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
`null.cshtml` ; corrigé en `Layout = null` (null C#).
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
`lualatex`.
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
d'erreur, et le `.log` est conservé en cas d'échec.
## [1.0.8-rc15] - unstable
### Added
* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition.
* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`.
* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis.
* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant.
* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role.
* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire.
* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd.
* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production.
* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`).
* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`).
* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant.
* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test).
* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle.
* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV.
* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe).
* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`.
* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test.
### Changed
* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page.
* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`.
* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire.
* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller).
* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite.
* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`.
* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu.
### Fixed
* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide).
* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement.
* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart).
* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts.
* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot.
## [1.0.8-rc14] - unstable
### Added
@ -329,6 +445,8 @@ the ACL now comes along with the article,
the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -2,5 +2,16 @@
<PropertyGroup>
<RootNamespace>Yavsc</RootNamespace>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
<!-- Disable the StaticWebAssets build cache (the *.dswa.cache.json
files written under obj/.../ by DefineStaticWebAssets).
ReadOrCreateCache opens them with File.OpenRead and no
try/catch, so under parallel solution builds a sharing
violation / TOCTOU race on the cache file escapes as
MSB4018 and fails the whole build (seen on Yavsc.Blogs
during the rc15 solution build). The cache is a pure
build-speed optimization; turning it off has no effect on
output correctness, only on incremental rebuild speed of
the (wwwroot-light) web projects. -->
<StaticWebAssetsCacheDefineStaticWebAssetsEnabled>false</StaticWebAssetsCacheDefineStaticWebAssetsEnabled>
</PropertyGroup>
</Project>

View file

@ -4,53 +4,71 @@
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
<PackageVersion Include="MimeKit" Version="4.18.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
<PackageVersion Include="pazof.rules" Version="1.1.3" />
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
</ItemGroup>
</Project>

View file

@ -13,6 +13,10 @@ all:
clean:
dotnet clean -c $(CONFIG)
brutal_clean:
@find -name "obj" -exec rm -rf {} \;
@find -name "bin" -exec rm -rf {} \;
src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
@ -48,70 +52,4 @@ docker-build:
docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc
# Crée une branche release/<V> depuis main, met à jour les
# `<Version>` des .csproj via dotnet-gitversion, et la
# pousse sur origin.
#
# Usage : make release V=1.0.7-rc1
#
# Pré-requis : être sur main, working tree clean. La cible
# vérifie les deux et refuse sinon — elle ne fait JAMAIS
# de checkout automatique, c'est à l'opérateur de s'être
# positionné sur la bonne branche au préalable (sinon le
# bump pourrait partir sur une branche tierce par accident).
#
# Notes :
# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
# donne release/1.0.7-rc1). C'est une étiquette d'intention,
# pas la version assembly.
# - La version dans les .csproj vient de GitVersion qui la
# calcule depuis l'historique git (tag le plus proche +
# nombre de commits). C'est la version assembly réelle.
# - L'ordre (fetch → branche → bump → push) garantit qu'on
# part d'un main synchro et qu'on ne pollue pas main avec
# le bump (qui vit sur la branche release).
# - Fail-fast si la branche existe déjà en local ou sur origin.
release:
@if [ -z "$(V)" ]; then \
echo "Usage: make release V=<version>"; \
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \
exit 1; \
fi
@BRANCH="release/$(V)"; \
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
echo "La branche $$BRANCH existe déjà en local."; \
echo " Pour la supprimer : git branch -D $$BRANCH"; \
exit 1; \
fi; \
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
echo "La branche $$BRANCH existe déjà sur origin."; \
exit 1; \
fi; \
echo "==> Fetch + vérification synchro main"; \
git fetch origin main; \
if ! git merge-base --is-ancestor origin/main HEAD; then \
echo "main a avancé plus loin que HEAD. Fais :"; \
echo " git pull --ff-only origin main"; \
exit 1; \
fi; \
echo "==> Création de $$BRANCH depuis main"; \
git checkout -b "$$BRANCH"; \
echo "==> dotnet-gitversion /updateprojectfiles"; \
dotnet-gitversion /updateprojectfiles; \
echo "==> Commit du bump"; \
git add .; \
if git diff --cached --quiet; then \
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
else \
git commit -m "chore(release): bump version via gitversion for $(V)"; \
fi; \
echo "==> Push de $$BRANCH sur origin"; \
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
.PHONY: test release
.PHONY: test install docker-image docker-build docker-run

View file

@ -61,9 +61,40 @@ make test
### les services et l'API
#### Dépendances système au runtime
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
systemd `yavscApi`.
Sous Debian/Ubuntu :
```bash
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
```
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
(`Emergency stop`, aucun PDF).
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
de compiler le document hors-ligne.
### La Prod
`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`.
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
puis, pour une première installation
`make install_service`.

View file

@ -1,23 +1,32 @@
DESTDIR=
DOTNET_FRAMEWORK=net10.0
APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=..
APP_INSTALL_ENV=preprod
APP_INSTALL_PREFIX=preprod-
include $(SLNDIR)/.env
include .env
include .$(APP_INSTALL_ENV).env
generated/:
@mkdir -p $@
reinstall: copy-binaries
@for project in $(APP_PROJECT_NAMES); do \
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
@echo "💯 backend started"
generated/yavscOrg.service:
generated/yavscBlogs.service:
generated/yavscApi.service:
reinstall-prod:
make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \
APP_SYSLOG_ID="yavsc$*" \
APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \
POSTGRES_PORT="$(POSTGRES_PORT)" \
POSTGRES_DB="$(POSTGRES_DB)" \
@ -34,11 +43,13 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
envsubst > $@
@echo Created service file: $@
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-service-Api: /etc/systemd/system/yavscApi.service
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
install-services: install-service-Org install-service-Blogs install-service-Api
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \
@ -52,39 +63,36 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
sync ; \
ls $${BINDIR}/appsettings* 2>&1 ; \
sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \
sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
@sync
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
sudo cp $^ $@
sudo chown root:root $@
build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
install: build_publish copy-binaries copy-services
install: copy-binaries copy-services
@sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \
do \
sudo systemctl enable yavsc$${project} ; \
sudo systemctl start yavsc$${project} ; \
echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavsc$${project} ; done
stop-services:
@for project in $(APP_PROJECT_NAMES); \
do \
sudo systemctl stop yavsc$${project} ; \
echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
@ -93,8 +101,19 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
@echo APPDIR: $(APPDIR)
showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
showOrgLogs:
@sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
showBlogsLogs:
@sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
clean:
@rm -rf generated
generated/:
@mkdir -p $@

View file

@ -1,5 +0,0 @@
# Read me
## Note aux icones
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩

View file

@ -10,10 +10,10 @@ RestartSec=5s
Type=simple
User=yavsc
Group=yavsc
WorkingDirectory=$DESTDIR
ExecStart=$DESTDIR/Yavsc.$APP_NAME
WorkingDirectory=$APPDIR
ExecStart=$APPDIR/Yavsc.$APP_NAME
Restart=always
Environment="HOME=$DESTDIR"
Environment="HOME=$APPDIR"
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
Environment="ASPNETCORE_Environment=$ENV"
@ -29,8 +29,6 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=$APP_SYSLOG_ID
[Install]

View file

@ -1,54 +0,0 @@
server {
if ($host = yavsc.pschneider.fr) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name yavsc.pschneider.fr;
location / {
root /srv/www/yavsc/wwwroot;
dav_methods PUT;
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name yavsc.pschneider.fr;
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
# Configure the SignalR Endpoint
location / {
# App server url
proxy_pass http://localhost:3003;
proxy_cache off;
# WebSockets were implemented after http/1.0
proxy_http_version 1.1;
# Configuration for ServerSentEvents
proxy_buffering off;
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
proxy_read_timeout 100s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View file

@ -1,5 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {}
"tools": {
"picket": {
"version": "0.2.12",
"commands": [
"picket"
],
"rollForward": false
}
}
}

5
global.json Normal file
View file

@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View file

@ -1,35 +1,34 @@
<Project>
<Import
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt-product-specific versions -->
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
</ItemGroup>
</Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project>

View file

@ -11,8 +11,8 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">

View file

@ -6,8 +6,8 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Browser" />

View file

@ -7,8 +7,8 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>

View file

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

View file

@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
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

@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
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

@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests
Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
Assert.True(vm.Queries.Count > 0);
}
private sealed class StubBillingApi : IYavscApiClient
@ -129,6 +127,12 @@ public class BillingQueriesPageViewModelTests
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

@ -62,4 +62,17 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
});
return Task.FromResult(default(T)!);
}
/// <summary>Multipart overload: BlogApiClient routes
/// Create/UpdatePostAsync with attachments through here.
/// Without this override the call would fall through to the
/// base <see cref="YavscApiClient"/> and attempt a real HTTP
/// round-trip against the stub authority. The body is a
/// factory, so we record the delegate itself; tests that
/// care about the multipart shape invoke it.</summary>
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
_recorder.Calls.Add((method, path, contentFactory));
return Task.FromResult(default(T)!);
}
}

View file

@ -0,0 +1,79 @@
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// Pins the markdown link that <see cref="BlogsViewModel.SaveAsync"/>
/// appends to the article after a successful attachment upload.
///
/// <para>The link must be an absolute URL derived from the OIDC
/// authority via <c>FileServerUrlHelpers.GetUserFilesUri</c>
/// (Yavsc.Abstract): the <c>/files</c> file server is hosted by
/// Yavsc.Org — the IdP — not by the blogs API host, so a link
/// built from <c>BlogsApiUrl</c> (or left relative) 404s when the
/// reader opens the post. The regression this guards: a generated
/// link of the shape <c>/files/{user}/blogs/{id}/{name}</c>
/// (relative, authority-less) instead of
/// <c>https://{authority}/files/{user}/blogs/{id}/{name}</c>.</para>
/// </summary>
public class BlogAttachmentLinkTests
{
[Fact]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
{
const string authority = "https://idp.example.test";
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/");
var settings = new Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority,
ClientId = "stub",
Scopes = new[] { "openid" },
},
};
var vm = new BlogsViewModel(blog, settings);
// Update branch: an existing post is selected, the user
// edits the buffers and attaches a file.
vm.SelectedPost = new BlogPostDto
{
Id = 42,
AuthorId = "tester",
Title = "Avant",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile(
"note.txt",
System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain"));
await vm.SaveAsync();
// The update branch issues two PUTs: the multipart one
// carrying the file, then — because TryAppendAttachmentLinks
// appended a link — a JSON one carrying the final article.
var linkPut = recorder.Calls.LastOrDefault(
c => c.method == HttpMethod.Put);
Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
var sentStringContent = multipartContent.First() as StringContent ;
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
// The regression shape: a relative, authority-less link.
Assert.DoesNotContain("](/files/", sentString);
}
}

View file

@ -0,0 +1,270 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateEditionPageViewModelTests
{
private static BillingQuerySummaryDto SampleQuery() => new()
{
Id = 42,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
};
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
}
[Fact]
public void Constructor_prefills_description_and_adds_a_first_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
Assert.Equal("Coupe simple", vm.EstimateDescription);
Assert.Single(vm.Lines);
Assert.Same(vm.Lines[0], vm.SelectedLine);
Assert.Contains("#42", vm.ContextLabel);
Assert.Contains("cli-1", vm.ContextLabel);
}
[Fact]
public void AddLine_appends_and_selects_the_new_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.AddLineCommand.Execute(null);
Assert.Equal(2, vm.Lines.Count);
Assert.Same(vm.Lines[1], vm.SelectedLine);
}
[Fact]
public void RemoveLine_removes_the_selected_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
var first = vm.Lines[0];
vm.RemoveLineCommand.Execute(null);
Assert.Empty(vm.Lines);
Assert.Null(vm.SelectedLine);
Assert.False(vm.RemoveLineCommand.CanExecute(null));
Assert.DoesNotContain(first, vm.Lines);
}
[Fact]
public void Total_sums_line_totals_and_tracks_edits()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Count = 2;
vm.Lines[0].UnitaryCost = 15.5m;
Assert.Equal(31m, vm.Total);
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
vm.AddLineCommand.Execute(null);
vm.Lines[1].Count = 1;
vm.Lines[1].UnitaryCost = 9m;
Assert.Equal(40m, vm.Total);
}
[Fact]
public async Task Send_without_title_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("titre", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_without_any_line_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines.Clear();
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("ligne", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Description = "Oubli du nom";
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("nom", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = " Devis coupe ";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].Count = 2.4m;
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
Assert.Equal("Devis coupe", payload.Title);
Assert.Equal("Coupe simple", payload.Description);
Assert.Empty(payload.AttachedFiles);
Assert.Empty(payload.AttachedGraphics);
var line = Assert.Single(payload.Bill);
Assert.Equal("Coupe", line.Name);
Assert.Equal(2, line.Count);
Assert.Equal(25m, line.UnitaryCost);
Assert.Equal("EUR", line.Currency);
}
[Fact]
public async Task Send_marks_the_page_as_sent_and_disables_resend()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.True(vm.HasSent);
Assert.False(vm.SendCommand.CanExecute(null));
Assert.Equal("Devis envoyé", vm.SendLabel);
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
Assert.Contains("#7", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_surfaces_server_errors_as_error_status()
{
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
await vm.SendCommand.ExecuteAsync(null);
Assert.False(vm.HasSent);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
Assert.True(vm.SendCommand.CanExecute(null));
}
[Fact]
public async Task Send_accepts_negative_amounts_for_discount_lines()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis avec remise";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
vm.AddLineCommand.Execute(null);
vm.Lines[1].Name = "Remise fidélité";
vm.Lines[1].Description = "Remise client régulier";
vm.Lines[1].UnitaryCost = -5m;
Assert.Equal(20m, vm.Total);
await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
}
private sealed class StubEstimateApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public HttpMethod? LastMethod { get; private set; }
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (Failure is not null)
{
throw Failure;
}
if (typeof(T) == typeof(EstimateCreatedDto))
{
var payload = (EstimateDto)body!;
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
return Task.FromResult((T)(object)created);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,138 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateListPageViewModelTests
{
[Fact]
public async Task Client_perspective_calls_asclient_endpoint()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
Assert.Equal("Devis en attente de signature client", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
// Most recent first
Assert.Equal(2, vm.Estimates[0].Id);
Assert.Equal(1, vm.Estimates[1].Id);
}
[Fact]
public async Task Provider_perspective_calls_asprovider_endpoint()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
}
[Fact]
public async Task FilterText_filters_by_title_and_counterpart()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
vm.FilterText = "plomberie";
Assert.Single(vm.Estimates);
Assert.Equal(1, vm.Estimates[0].Id);
vm.FilterText = "BOB";
Assert.Single(vm.Estimates);
Assert.Equal("bob", vm.Estimates[0].ClientId);
vm.FilterText = string.Empty;
Assert.Equal(2, vm.Estimates.Count);
}
[Fact]
public async Task Empty_result_sets_an_info_status()
{
var api = new StubEstimateListApi { Empty = true };
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();
Assert.Empty(vm.Estimates);
Assert.Contains("Aucun devis", vm.StatusMessage);
}
private sealed class StubEstimateListApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public bool Empty { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<EstimateDto>))
{
var items = Empty
? new List<EstimateDto>()
: new List<EstimateDto>
{
new()
{
Id = 1,
Title = "Devis plomberie",
Description = "Réparation fuite",
ClientId = "bob",
OwnerId = "alice",
CommandType = "Rdv",
Bill = new List<EstimateLineDto>
{
new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m },
},
},
new()
{
Id = 2,
Title = "Devis électricité",
Description = "Mise aux normes",
ClientId = "carol",
OwnerId = "alice",
CommandType = "Rdv",
Bill = new List<EstimateLineDto>(),
},
};
return Task.FromResult((T)(object)items);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using PostIt.Controls;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
/// signature pads (provider + client) into the right
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
/// is not tested here; the VM is driven through the same
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
/// and the API client through a recording fake
/// <see cref="IYavscApiClient"/>. The test host's
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
/// run to completion without navigation side effects.
/// </summary>
public class EstimateValidationPageViewModelTests
{
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
{
Id = 7,
CommandId = commandId,
CommandType = "Rdv",
Title = "Devis plomberie",
Description = "Réparation",
ClientId = "bob",
OwnerId = "alice",
};
private static void DrawOneStroke(SignaturePadControl pad)
{
pad.AppendPointForTest(100, 200);
pad.AppendPointForTest(300, 400);
pad.SealStrokeForTest();
}
[Fact]
public async Task Validate_calls_accept_with_strokes_and_command_id()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var clientPad = new SignaturePadControl();
vm.Attach(proPad, clientPad);
DrawOneStroke(proPad); // provider signs on their writable pad
await vm.ValidateAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/accept", api.LastPath);
Assert.Contains("billingCode=Rdv", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.NotNull(body.Strokes);
Assert.NotEmpty(body.Strokes);
Assert.Equal(10_000, body.CoordinateMax);
// The provider perspective declares the Pro side, so the
// server stores the signature under the provider's author
// regardless of role inference.
Assert.Equal(SignatureType.Pro, body.SignatureType);
}
[Fact]
public async Task Validate_from_client_perspective_declares_client_side()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var cliPad = new SignaturePadControl();
vm.Attach(new SignaturePadControl(), cliPad);
DrawOneStroke(cliPad); // client signs on their writable pad
await vm.ValidateAsync();
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
}
[Fact]
public async Task Reject_calls_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
await vm.RejectAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/reject", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Validate_without_signature_warns_and_does_not_call()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
await vm.ValidateAsync(); // no throw: returns before GoBack
Assert.Null(api.LastMethod);
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_command_gates_on_signature_and_command()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
vm.Attach(proPad, new SignaturePadControl());
// No CommandId ⇒ cannot validate even after signing.
DrawOneStroke(proPad);
Assert.False(vm.ValidateCommand.CanExecute(null));
var vm2 = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad2 = new SignaturePadControl();
vm2.Attach(proPad2, new SignaturePadControl());
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
DrawOneStroke(proPad2);
Assert.True(vm2.ValidateCommand.CanExecute(null));
}
[Fact]
public void RoleLabel_reflects_perspective()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var pro = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var cli = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Only_the_author_pad_is_writable()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var proVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
proVm.Attach(proPad, cliPad);
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
Assert.True(cliPad.IsReadOnly);
var cliVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var proPad2 = new SignaturePadControl();
var cliPad2 = new SignaturePadControl();
cliVm.Attach(proPad2, cliPad2);
Assert.True(proPad2.IsReadOnly);
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
}
[Fact]
public void Load_existing_signatures_renders_both_sides_in_their_pads()
{
var proStrokes = new[] { 1, 1000, 1000 };
var cliStrokes = new[] { 1, 9000, 9000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
// Client perspective: both pads show the stored signatures, but
// only the client pad is writable.
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
Assert.True(vm.HasProSignature);
Assert.True(vm.HasClientSignature);
}
[Fact]
public void Load_existing_pro_signature_renders_in_pro_pad_only()
{
var strokes = new[] { 1, 5000, 5000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(strokes, proPad.Snapshot().Strokes);
Assert.True(cliPad.Snapshot().IsEmpty);
Assert.True(vm.HasProSignature);
Assert.False(vm.HasClientSignature);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

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

View file

@ -0,0 +1,15 @@
using PostIt.ViewModels;
namespace PostIt.Tests;
public class HomePageProviderFlowTests
{
[Fact]
public void HomePage_exposes_provider_requests_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null));
}
}

View file

@ -8,6 +8,8 @@ using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Helpers;
namespace PostIt.Tests;
@ -72,13 +74,13 @@ public class MainPageButtonsTests
{ }
}
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// Minimal DI graph: only what BlogsViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
@ -93,7 +95,7 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
@ -101,7 +103,7 @@ public class MainPageButtonsTests
/// <summary>
/// Mount a real <see cref="MainView"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <see cref="BlogsPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
@ -109,13 +111,17 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm)
{
var window = new MainView();
var page = new MainPage { DataContext = vm };
var page = new BlogsPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
app.AttachMainWindow(window);
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
var mainWindow = new Window { Content = window };
mainWindow.Show();
return (window, page);
}
@ -145,7 +151,7 @@ public class MainPageButtonsTests
}
[AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack()
public async Task Acl_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is
@ -178,7 +184,7 @@ public class MainPageButtonsTests
}
[AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack()
public async Task Circles_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
@ -203,7 +209,7 @@ public class MainPageButtonsTests
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// BlogsViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via

View file

@ -5,10 +5,11 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// Headless UI tests for the "Save" flow in <see cref="BlogsPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
@ -40,9 +41,9 @@ public class MainPageSaveTests
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
var page = new MainPage { DataContext = viewModel };
var page = new BlogsPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the
@ -63,9 +64,7 @@ public class MainPageSaveTests
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
var saveButton = page.SaveButton;
saveButton.Command!.Execute(null);
// The Save command is async (RelayCommand over Task) but

View file

@ -270,6 +270,12 @@ public class PostAclDialogTests
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

@ -8,14 +8,13 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />

View file

@ -11,25 +11,23 @@ public class PostItViewModelTests
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
{
// MainPageViewModel no longer owns a BlogApiClient instance by
// BlogsViewModel no longer owns a BlogApiClient instance by
// default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
@ -60,7 +58,7 @@ public class PostItViewModelTests
{
var api = new RecordingPublishApi();
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
@ -146,6 +144,12 @@ public class PostItViewModelTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,238 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ProviderOngoingRequestsPageViewModelTests
{
[Fact]
public async Task RefreshAsync_calls_provider_endpoint_and_filters_out_unknown_billing_codes()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/bill/provider/ongoing", api.Paths);
Assert.Equal(3, vm.Queries.Count);
Assert.Equal(12, vm.Queries[0].Id);
Assert.Equal(11, vm.Queries[1].Id);
Assert.Equal(10, vm.Queries[2].Id);
}
[Fact]
public async Task FilterText_filters_by_activity_code_and_status()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.FilterText = "mbrush";
Assert.Single(vm.Queries);
Assert.Equal("MBrush", vm.Queries[0].BillingCode);
vm.FilterText = "accepted";
Assert.Single(vm.Queries);
Assert.Equal(QueryStatus.Accepted, vm.Queries[0].Status);
}
[Fact]
public async Task OpenSelectedEditorCommand_can_execute_only_when_selection_exists()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.False(vm.OpenSelectedEditorCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.OpenSelectedEditorCommand.CanExecute(null));
}
[Fact]
public async Task SelectedSortOption_date_keeps_most_recent_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDate;
Assert.Equal(new long[] { 12, 11, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_date_ascending_keeps_oldest_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDateAsc;
Assert.Equal(new long[] { 10, 11, 12 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_status_prioritizes_inprogress_then_accepted_then_inserted()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus;
Assert.Equal(new long[] { 11, 12, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public void Constructor_reads_saved_sort_option_from_settings()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus,
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByStatus, vm.SelectedSortOption);
}
[Fact]
public void Constructor_falls_back_to_default_when_saved_sort_is_invalid()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = "invalide",
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
}
[Fact]
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
await vm.InitializeAsync();
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
[Fact]
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
private sealed class StubProviderApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
new()
{
Id = 10,
BillingCode = "Rdv",
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Inserted,
Description = "Rendez-vous",
EventDate = new DateTime(2026, 9, 10, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 11,
BillingCode = "MBrush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-2",
Status = QueryStatus.InProgress,
Description = "Coupe multiple",
EventDate = new DateTime(2026, 9, 11, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 12,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-3",
Status = QueryStatus.Accepted,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 13,
BillingCode = "",
ActivityCode = "unknown",
PerformerId = "perf-1",
ClientId = "cli-4",
Status = QueryStatus.Accepted,
Description = "Doit être filtrée",
EventDate = new DateTime(2026, 9, 13, 10, 0, 0, DateTimeKind.Utc),
}
};
return Task.FromResult((T)(object)data);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
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

@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> 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

@ -11,5 +11,6 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles>
</Application>

View file

@ -17,6 +17,7 @@ namespace PostIt;
public partial class App : Application
{
private int _bootStarted;
public string? CliConfigFileSpecification {  get; private set; }
/// <summary>
/// DI container the platform entry points hand to ViewModels so
@ -46,35 +47,48 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings);
InitializeClassicDesktopLifetime(settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
View = ServiceProvider.GetRequiredService<MainView>();
this.ConfigureRootView(View);
ApplyDarkMode(settings);
return View;
};
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureRootView(MainView rootView)
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
}
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyDarkMode(settings);
return View!;
};
}
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider!.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings);
}
public void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
@ -87,6 +101,7 @@ private void ConfigureRootView(MainView rootView)
};
rootView.SessionBanner.DataContext = sessionStatus;
this.View = rootView;
}
private async Task BootOnceAsync()
@ -148,7 +163,7 @@ private void ConfigureRootView(MainView rootView)
public static async Task PushBlogsPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
var mainVm = app.ServiceProvider!.GetRequiredService<BlogsViewModel>();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm);
}
@ -189,4 +204,9 @@ private void ConfigureRootView(MainView rootView)
{
await View!.NavRoot.PopAsync();
}
internal void UseConfigFileWhenLoading(string configFile)
{
this.CliConfigFileSpecification = configFile;
}
}

View file

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Mono.Options;
using PostIt;
using PostIt.ViewModels;
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
List<string> extra = options.Parse(args);
if (showHelp)
{
Console.WriteLine("Usage: app [OPTIONS]+");
options.WriteOptionDescriptions(Console.Out);
return;
}
if (configFile != null)
{
var app = App.Current as App;
app.UseConfigFileWhenLoading(configFile);
}
}
}

View file

@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value);
}
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary>
/// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea);
_capturing = true;
@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
/// Loaded strokes are all sealed (there is no pending stroke).
/// Malformed input (a non-positive length prefix, or a stroke that
/// runs past the end of the array) is truncated at the offending
/// point so the buffer never holds an inconsistent state.
/// </summary>
public void Load(int[] strokes)
{
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
_strokes.Add(k);
for (int p = 0; p < 2 * k; p++)
_strokes.Add(strokes[i + 1 + p]);
i += 1 + 2 * k;
}
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Commands;
using Yavsc.Api.Client;
@ -29,12 +30,14 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues
services.AddSingleton<MainView>();
services.AddSingleton<MainPage>();
services.AddSingleton<BlogsPage>();
services.AddSingleton<MainWindow>();
// SettingsPage is a singleton: there must be one and only one
@ -58,6 +61,11 @@ public static class ServiceCollectionHelpers
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -67,6 +75,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
@ -89,7 +99,7 @@ public static class ServiceCollectionHelpers
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddSingleton<SessionStatusBanner>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<BlogsViewModel>();
return services.BuildServiceProvider();
}
}

View file

@ -8,7 +8,7 @@ namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
public static async Task<Page> PushPageAsync(this App app, ViewModelBase vm)
{
var window = app.View;
if (window is null)
@ -43,9 +43,11 @@ public static class ViewModelBaseHelpers
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
return page;
}
await window.NavRoot.PushAsync(page);
return page;
}
}

View file

@ -5,8 +5,8 @@
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@ -23,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
@ -33,7 +34,9 @@
<PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -191,13 +191,52 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
return dto!;
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Call a multipart endpoint, transparently refreshing the token if needed.
/// </summary>
public virtual async Task<T> CallAsync<T>(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Deserialise the response body as <typeparamref name="T"/>,
/// tolerating an empty body. <c>PUT /blogspot/{id}</c> answers
/// <c>204 No Content</c>: without this guard,
/// <c>JsonSerializer.DeserializeAsync</c> throws
/// <c>JsonException: The input does not contain any JSON
/// tokens</c> and the VM surfaces a successful save as an
/// error. Callers that genuinely need a payload
/// (<c>GetPostAsync</c>, <c>CreatePostAsync</c>) are unaffected:
/// their endpoints always return a body.
/// </summary>
private static async Task<T> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
if (response.StatusCode == HttpStatusCode.NoContent)
return default!;
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return default!;
return JsonSerializer.Deserialize<T>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
/// <summary>
@ -217,7 +256,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Call a multipart endpoint that returns no useful body (DELETE, etc.).
/// </summary>
public async Task CallAsync(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
@ -232,7 +287,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
=> CallAsync(method, path, body: null, ct);
private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
HttpMethod method, string path, Func<HttpContent>? contentFactory, CancellationToken ct)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -240,8 +295,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(method, path);
if (body is not null)
req.Content = JsonContent.Create(body);
if (contentFactory is not null)
req.Content = contentFactory();
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
@ -252,8 +307,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await ForceRefreshAsync(ct).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path);
if (body is not null)
retry.Content = JsonContent.Create(body);
if (contentFactory is not null)
retry.Content = contentFactory();
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
}
@ -423,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
return "Avatar mis à jour.";
}
/// <summary>
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
/// contract as <see cref="SetAvatarAsync"/>.
/// </summary>
public virtual async Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public async Task LogoutAsync()
{
_store.Clear();

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Commands;
namespace PostIt;
@ -38,7 +39,7 @@ public class ViewLocator : IDataTemplate
var services = app!.ServiceProvider!;
return data switch
{
MainViewModel => services.GetRequiredService<MainPage>(),
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
@ -51,6 +52,10 @@ public class ViewLocator : IDataTemplate
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
@ -43,7 +44,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => true;
public bool CanOpenDetails => !IsReadOnly;
public override bool CanNavigateNext
{
@ -75,7 +76,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null;
[RelayCommand]
public async Task RefreshAsync()
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
Activity,
Performer,
Form,
_billingClient,
frontClient,
details,
IsReadOnly);
await app.PushPageAsync(vm).ConfigureAwait(true);
@ -171,5 +174,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
}
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
=> status is QueryStatus.Inserted
or QueryStatus.Accepted
or QueryStatus.ProAccepted
or QueryStatus.ClientAccepted
or QueryStatus.InProgress;
}

View file

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

View file

@ -0,0 +1,293 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Edition d'un devis (<c>Estimate</c>) créé en réponse à une demande
/// client (<see cref="BillingQuerySummaryDto"/>) consultée depuis la
/// page « Mes demandes en cours ». L'envoi poste le devis sur
/// <c>api/v1/estimate</c>; côté serveur, la commande liée
/// (<see cref="BillingQuerySummaryDto.Id"/>) est alors marquée comme
/// validée par le prestataire.
/// </summary>
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id;
public string ClientId => _query.ClientId;
public string BillingCode => _query.BillingCode;
public string Title => $"Devis — demande #{_query.Id}";
public string ContextLabel
=> $"Demande #{_query.Id} · {BillingCode} · client {ClientId}";
public string QueryDescription => string.IsNullOrWhiteSpace(_query.Description)
? "(sans description)"
: _query.Description;
[ObservableProperty]
public partial string EstimateTitle { get; set; } = string.Empty;
[ObservableProperty]
public partial string EstimateDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<EstimateLineItemViewModel> Lines { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))]
public partial EstimateLineItemViewModel? SelectedLine { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool IsBusy { get; set; }
/// <summary>
/// True une fois le devis accepté par le serveur: l'envoi est
/// désactivé pour éviter les doublons, il ne reste que « Retour ».
/// </summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool HasSent { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
public string SendLabel => HasSent ? "Devis envoyé" : "Envoyer le devis";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
{
_query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged;
AddLine();
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
}
[RelayCommand]
private void AddLine()
{
var line = new EstimateLineItemViewModel();
Lines.Add(line);
SelectedLine = line;
}
private bool CanRemoveLine() => SelectedLine is not null && !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanRemoveLine))]
private void RemoveLine()
{
if (SelectedLine is null)
{
return;
}
var index = Lines.IndexOf(SelectedLine);
Lines.Remove(SelectedLine);
SelectedLine = Lines.Count == 0
? null
: Lines[Math.Min(index, Lines.Count - 1)];
}
private bool CanSend() => !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanSend))]
private async Task SendAsync()
{
if (!TryValidate(out var validationMessage))
{
this.SetWarningStatus(validationMessage);
return;
}
IsBusy = true;
try
{
var payload = BuildPayload();
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true;
OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé à l'API devis (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi du devis: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
internal EstimateDto BuildPayload()
{
return new EstimateDto
{
CommandId = QueryId,
ClientId = ClientId,
CommandType = BillingCode,
Title = EstimateTitle.Trim(),
Description = EstimateDescription.Trim(),
Bill = Lines.Select(line => new EstimateLineDto
{
Id = line.Id,
Name = line.Name.Trim(),
Description = line.Description.Trim(),
Count = Math.Max(1, (int)Math.Round(line.Count)),
UnitaryCost = line.UnitaryCost,
Currency = string.IsNullOrWhiteSpace(line.Currency) ? "EUR" : line.Currency.Trim(),
}).ToList(),
};
}
private bool TryValidate(out string message)
{
if (string.IsNullOrWhiteSpace(EstimateTitle))
{
message = "Le titre du devis est requis.";
return false;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
message = "La demande sélectionnée n'identifie pas de client.";
return false;
}
if (string.IsNullOrWhiteSpace(BillingCode))
{
message = "La demande sélectionnée n'a pas de code de facturation.";
return false;
}
if (Lines.Count == 0)
{
message = "Ajoutez au moins une ligne au devis.";
return false;
}
foreach (var line in Lines)
{
if (string.IsNullOrWhiteSpace(line.Name))
{
message = "Chaque ligne doit avoir un nom.";
return false;
}
if (line.Name.Trim().Length > 256)
{
message = $"Le nom de la ligne « {line.Name.Trim()[..20]}… » dépasse 256 caractères.";
return false;
}
if (string.IsNullOrWhiteSpace(line.Description))
{
message = $"La ligne « {line.Name.Trim()} » doit avoir une description.";
return false;
}
if (line.Description.Trim().Length > 512)
{
message = $"La description de la ligne « {line.Name.Trim()} » dépasse 512 caractères.";
return false;
}
if (line.Count < 1)
{
message = $"La quantité de la ligne « {line.Name.Trim()} » doit être d'au moins 1.";
return false;
}
}
message = string.Empty;
return true;
}
private void OnLinesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged -= OnLinePropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged += OnLinePropertyChanged;
}
}
RaiseTotalsChanged();
}
private void OnLinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(EstimateLineItemViewModel.LineTotal)
or nameof(EstimateLineItemViewModel.Currency))
{
RaiseTotalsChanged();
}
}
private void RaiseTotalsChanged()
{
OnPropertyChanged(nameof(Total));
OnPropertyChanged(nameof(TotalLabel));
}
}

View file

@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
/// <summary>
/// Editable estimate line. <see cref="Count"/> is exposed as a
/// <see cref="decimal"/> so it binds directly to
/// <c>NumericUpDown.Value</c> (<c>decimal?</c>); it is rounded back
/// to an integer when the DTO is built.
/// </summary>
public partial class EstimateLineItemViewModel : ObservableObject
{
public long Id { get; set; }
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
[ObservableProperty]
public partial string Description { get; set; } = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal Count { get; set; } = 1m;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal UnitaryCost { get; set; }
[ObservableProperty]
public partial string Currency { get; set; } = "EUR";
public decimal LineTotal => Count * UnitaryCost;
public string LineTotalLabel => $"{LineTotal:0.00}";
}

View file

@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Point de vue d'une liste de devis en cours (non encore validés par
/// le client).
/// </summary>
public enum EstimateListPerspective
{
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
Client,
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
Provider,
}
/// <summary>
/// Liste des devis en cours servie par <c>GET api/v1/estimate/asclient</c>
/// ou <c>GET api/v1/estimate/asprovider</c> selon la perspective choisie.
/// </summary>
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _allEstimates = new();
public EstimateListPerspective Perspective => _perspective;
public string Title => _perspective == EstimateListPerspective.Client
? "Devis en attente de signature client"
: "Devis en attente de signature fournisseur";
public string Subtitle => _perspective == EstimateListPerspective.Client
? "Point de vue client"
: "Point de vue fournisseur";
/// <summary>
/// Libellé de la colonne « autre partie » : côté client on montre
/// le fournisseur (OwnerId), côté fournisseur on montre le client.
/// </summary>
public string CounterpartLabel => _perspective == EstimateListPerspective.Client
? "Fournisseur"
: "Client";
[ObservableProperty]
public partial ObservableCollection<EstimateDto> Estimates { get; set; } = new();
[ObservableProperty]
public partial string FilterText { get; set; } = string.Empty;
[ObservableProperty]
public partial EstimateDto? SelectedEstimate { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des devis...";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des devis...");
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateListPageViewModel(
EstimateApiClient estimateClient,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var items = (_perspective == EstimateListPerspective.Client
? await _estimateClient.GetOngoingEstimatesAsClientAsync().ConfigureAwait(true)
: await _estimateClient.GetOngoingEstimatesAsProviderAsync().ConfigureAwait(true))
?? new List<EstimateDto>();
_allEstimates = items
.OrderByDescending(x => x.Id)
.ToList();
ApplyFilter();
this.SetInfoStatus(_allEstimates.Count == 0
? "Aucun devis en cours."
: $"{_allEstimates.Count} devis en cours chargé(s).");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
_allEstimates = new List<EstimateDto>();
Estimates = new ObservableCollection<EstimateDto>();
this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
_allEstimates = new List<EstimateDto>();
Estimates = new ObservableCollection<EstimateDto>();
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
partial void OnFilterTextChanged(string value)
{
ApplyFilter();
}
private void ApplyFilter()
{
var query = FilterText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? _allEstimates
: _allEstimates.Where(x =>
ContainsInsensitive(x.Title, query)
|| ContainsInsensitive(x.Description, query)
|| ContainsInsensitive(x.CommandType, query)
|| ContainsInsensitive(x.ClientId, query)
|| ContainsInsensitive(x.OwnerId, query))
.ToList();
Estimates = new ObservableCollection<EstimateDto>(filtered);
}
private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open the estimate validation (accept/reject with signature)
/// page for a specific estimate. The perspective the list was
/// opened in is forwarded as the actor-role UI hint; the server
/// independently enforces the real authorization.
/// </summary>
[RelayCommand]
private async Task OpenEstimateAsync(EstimateDto? estimate)
{
if (estimate is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
}

View file

@ -0,0 +1,399 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Controls;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
/// <summary>
/// Validation (accept / reject) of a single estimate, with optional
/// signatures. Backs the estimate validation page opened from
/// <see cref="EstimateListPageViewModel"/> for either perspective.
///
/// <para>Two signatures are shown, each in its own pad: the provider's
/// (Pro) and the client's (Client). Both are rendered from the estimate
/// payload when present, so reopening a partly-signed estimate shows
/// what was already captured. Only the pad matching the current
/// <see cref="EstimateListPerspective"/> is writable — the server
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
/// same rule (it resolves the caller's role from the authenticated user
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
/// the other pad read-only. The billing code sent to the server is the
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
/// its <see cref="EstimateDto.CommandId"/>.</para>
/// </summary>
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateDto _estimate;
private readonly EstimateListPerspective _perspective;
private readonly FrontOfficeApiClient _frontClient;
private SignaturePadControl? _proPad;
private SignaturePadControl? _clientPad;
public string Title => _estimate.Title;
public string Description => _estimate.Description;
public long EstimateId => _estimate.Id;
public string CommandType => _estimate.CommandType;
public IReadOnlyList<EstimateLineDto> Bill => _estimate.Bill;
public DateTime ProviderValidationDate => _estimate.ProviderValidationDate;
public DateTime ClientValidationDate => _estimate.ClientValidationDate;
public string RoleLabel => _perspective == EstimateListPerspective.Provider
? "Vous validez en tant que prestataire"
: "Vous validez en tant que client";
/// <summary>
/// True when the current user may sign the provider's signature
/// (i.e. they opened the list as the provider). The other party's
/// pad is read-only.
/// </summary>
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
/// <summary>
/// True when the current user may sign the client's signature
/// (i.e. they opened the list as the client).
/// </summary>
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
/// <summary>
/// Human-readable summary of who has already validated this
/// estimate, shown above the signature pads.
/// </summary>
public string ValidationStateText
{
get
{
var parts = new List<string>();
if (ProviderValidationDate != default)
parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}");
if (ClientValidationDate != default)
parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}");
return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts);
}
}
/// <summary>
/// True when the estimate has a linked query to accept/reject.
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
/// templates and cannot be validated through the front-office
/// endpoint.
/// </summary>
public bool HasCommand => _estimate.CommandId is not null;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Stroke/point counts for the writable pad.</summary>
[ObservableProperty]
public partial int StrokeCount { get; set; }
[ObservableProperty]
public partial int PointCount { get; set; }
/// <summary>True when the writable pad holds a signature.</summary>
[ObservableProperty]
public partial bool HasSignature { get; set; }
[ObservableProperty]
public partial bool HasProSignature { get; set; }
[ObservableProperty]
public partial bool HasClientSignature { get; set; }
[ObservableProperty]
public partial string ProSignatureHint { get; set; } = string.Empty;
[ObservableProperty]
public partial string ClientSignatureHint { get; set; } = string.Empty;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateValidationPageViewModel(
EstimateDto estimate,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimate = estimate ?? throw new ArgumentNullException(nameof(estimate));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
/// <summary>
/// Bind the page's two signature pads (the inner controls of the
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
/// the client's pad. Called from the view's code-behind once the
/// view is in the visual tree. Marks the non-author pad read-only,
/// then renders any signatures already carried on the estimate
/// payload.
/// </summary>
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
{
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
Detach();
_proPad = proPad;
_clientPad = clientPad;
_proPad.IsReadOnly = !CanWritePro;
_clientPad.IsReadOnly = !CanWriteClient;
_proPad.RedrawRequested += OnPadRedraw;
_clientPad.RedrawRequested += OnPadRedraw;
LoadExistingSignatures();
RefreshFromPad();
}
public void Detach()
{
if (_proPad is not null)
_proPad.RedrawRequested -= OnPadRedraw;
if (_clientPad is not null)
_clientPad.RedrawRequested -= OnPadRedraw;
_proPad = null;
_clientPad = null;
}
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
/// <summary>The pad the current user may sign in.</summary>
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
/// <summary>
/// Render the signatures already stored for this estimate back into
/// their pads. The strokes are carried on the estimate payload as
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
/// its own side directly, in the same wire format PostIt captured.
/// No network round-trip.
/// </summary>
public void LoadExistingSignatures()
{
LoadSide(_proPad, _estimate.SignaturePro);
LoadSide(_clientPad, _estimate.SignatureClient);
RefreshFromPad();
}
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
{
if (pad is null || sig is null) return;
if (sig.Strokes is { Length: > 0 } strokes)
pad.Load(strokes);
}
private void RefreshFromPad()
{
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
ProSignatureHint = CanWritePro
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
ClientSignatureHint = CanWriteClient
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
var pad = WritablePad;
if (pad is null) return;
var snap = pad.Snapshot();
StrokeCount = snap.StrokeCount;
PointCount = snap.PointCount;
HasSignature = !snap.IsEmpty;
}
partial void OnIsBusyChanged(bool value)
{
ValidateCommand.NotifyCanExecuteChanged();
RejectCommand.NotifyCanExecuteChanged();
}
partial void OnHasSignatureChanged(bool value)
=> ValidateCommand.NotifyCanExecuteChanged();
private bool CanValidate => !IsBusy && HasSignature && HasCommand;
private bool CanReject => !IsBusy && HasCommand;
[RelayCommand(CanExecute = nameof(CanValidate))]
public async Task ValidateAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
return;
}
var pad = WritablePad;
if (pad is null || pad.Snapshot().IsEmpty)
{
this.SetWarningStatus("Veuillez signer avant de valider.");
return;
}
var snap = pad.Snapshot();
IsBusy = true;
try
{
var body = new QueryAcceptanceRequestDto
{
Strokes = snap.Strokes,
CoordinateMax = SignaturePadData.CoordinateMax,
CapturedAtUtc = DateTime.UtcNow,
// Declare which side the caller is signing as, so the
// server stores the signature under the right author
// (Pro/Client) even when one user is both parties.
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
};
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
.ConfigureAwait(true);
this.SetInfoStatus("Devis validé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest)
{
this.SetWarningStatus($"Validation refusée: {ex.Message}");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanReject))]
public async Task RejectAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé.");
return;
}
IsBusy = true;
try
{
await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId)
.ConfigureAwait(true);
this.SetInfoStatus("Devis refusé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void ClearSignature()
{
WritablePad?.Clear();
RefreshFromPad();
}
/// <summary>
/// Download the estimate as a LaTeX source document. The server
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
/// user picks a destination via the platform save picker.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate as a compiled PDF
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
/// Requires <c>texi2pdf</c> on the server host; a failure is
/// surfaced as a status message.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
if (_estimate.CommandId is not long)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
return;
}
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
}

View file

@ -0,0 +1,395 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActionStatusViewModel
{
public const string SortByDate = "Date (plus récent d'abord)";
public const string SortByDateAsc = "Date (plus ancien d'abord)";
public const string SortByStatus = "Statut (en cours d'abord)";
private readonly BillingApiClient _billingClient;
private readonly EstimateApiClient? _estimateClient;
private readonly Settings? _settings;
private List<BillingQuerySummaryDto> _allQueries = new();
[ObservableProperty]
public partial ObservableCollection<BillingQuerySummaryDto> Queries { get; set; } = new();
[ObservableProperty]
public partial string FilterText { get; set; } = string.Empty;
public IReadOnlyList<string> SortOptions { get; } = new[]
{
SortByDate,
SortByDateAsc,
SortByStatus,
};
[ObservableProperty]
public partial string SelectedSortOption { get; set; } = SortByDate;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenSelectedEditorCommand))]
[NotifyCanExecuteChangedFor(nameof(CreateEstimateForSelectedCommand))]
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des demandes fournisseur...";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des demandes fournisseur...");
public string Title => "Mes demandes en cours";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ProviderOngoingRequestsPageViewModel(
BillingApiClient billingClient,
Settings? settings = null,
EstimateApiClient? estimateClient = null)
{
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_estimateClient = estimateClient;
_settings = settings;
if (_settings is not null)
{
var preferredSort = NormalizeSortOption(_settings.ProviderOngoingRequestsSortOption);
if (!string.Equals(preferredSort, SelectedSortOption, StringComparison.Ordinal))
{
SelectedSortOption = preferredSort;
}
}
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var items = await _billingClient.GetProviderOngoingQueriesAsync().ConfigureAwait(true) ?? new();
_allQueries = items
.Where(x => !string.IsNullOrWhiteSpace(x.BillingCode))
.OrderByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
ApplyFilter();
this.SetInfoStatus(_allQueries.Count == 0
? "Aucune demande en cours pour votre profil fournisseur."
: $"{_allQueries.Count} demande(s) en cours chargée(s).");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
_allQueries = new List<BillingQuerySummaryDto>();
Queries = new ObservableCollection<BillingQuerySummaryDto>();
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
_allQueries = new List<BillingQuerySummaryDto>();
Queries = new ObservableCollection<BillingQuerySummaryDto>();
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
private bool CanOpenSelectedEditor() => SelectedQuery is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
activity,
performer,
form,
_billingClient,
frontClient,
details,
isReadOnly: false);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanOpenSelectedEditor))]
public async Task OpenSelectedEditorAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var vm = form.CreateCommandPageViewModel(activity, performer, _billingClient);
if (vm is null)
{
this.SetWarningStatus($"Le formulaire '{form.ActionName}' n'est pas pris en charge en édition.");
return;
}
await vm.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture en édition: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private bool CanCreateEstimateForSelected() => SelectedQuery is not null && _estimateClient is not null;
[RelayCommand(CanExecute = nameof(CanCreateEstimateForSelected))]
public async Task CreateEstimateForSelectedAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
if (_estimateClient is null)
{
this.SetWarningStatus("Le client devis n'est pas disponible.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
partial void OnFilterTextChanged(string value)
{
ApplyFilter();
}
partial void OnSelectedSortOptionChanged(string value)
{
var normalized = NormalizeSortOption(value);
if (!string.Equals(normalized, value, StringComparison.Ordinal))
{
SelectedSortOption = normalized;
return;
}
PersistSortPreference(value);
ApplyFilter();
}
private void ApplyFilter()
{
var query = FilterText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? _allQueries
: _allQueries.Where(x =>
ContainsInsensitive(x.Description, query)
|| ContainsInsensitive(x.ActivityCode, query)
|| ContainsInsensitive(x.BillingCode, query)
|| ContainsInsensitive(x.ClientId, query)
|| ContainsInsensitive(x.Status.ToString(), query))
.ToList();
var sorted = ApplySort(filtered);
Queries = new ObservableCollection<BillingQuerySummaryDto>(sorted);
}
private List<BillingQuerySummaryDto> ApplySort(IEnumerable<BillingQuerySummaryDto> source)
{
if (string.Equals(SelectedSortOption, SortByStatus, StringComparison.Ordinal))
{
return source
.OrderBy(x => GetStatusRank(x.Status))
.ThenByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
}
if (string.Equals(SelectedSortOption, SortByDateAsc, StringComparison.Ordinal))
{
return source
.OrderBy(x => x.EventDate ?? DateTime.MinValue)
.ThenBy(x => x.Id)
.ToList();
}
return source
.OrderByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
}
private void PersistSortPreference(string selectedSort)
{
if (_settings is null)
{
return;
}
if (string.Equals(_settings.ProviderOngoingRequestsSortOption, selectedSort, StringComparison.Ordinal))
{
return;
}
_settings.ProviderOngoingRequestsSortOption = selectedSort;
try
{
_settings.Save();
}
catch
{
this.SetWarningStatus("Le tri a été appliqué, mais sa sauvegarde a échoué.");
}
}
private static string NormalizeSortOption(string? sortOption)
{
if (string.Equals(sortOption, SortByDate, StringComparison.Ordinal)
|| string.Equals(sortOption, SortByDateAsc, StringComparison.Ordinal)
|| string.Equals(sortOption, SortByStatus, StringComparison.Ordinal))
{
return sortOption!;
}
return SortByDate;
}
private static int GetStatusRank(QueryStatus status)
=> status switch
{
QueryStatus.InProgress => 0,
QueryStatus.ClientAccepted => 1,
QueryStatus.ProAccepted => 2,
QueryStatus.Accepted => 2,
QueryStatus.Inserted => 3,
QueryStatus.Success => 4,
QueryStatus.Rejected => 5,
QueryStatus.Failed => 6,
_ => 99,
};
private static bool ContainsInsensitive(string? source, string query)
=> !string.IsNullOrWhiteSpace(source)
&& source.Contains(query, StringComparison.OrdinalIgnoreCase);
private static (ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form)
BuildNavigationContext(BillingQuerySummaryDto query)
{
var activity = new ActivityInfo
{
Code = query.ActivityCode,
Name = string.IsNullOrWhiteSpace(query.ActivityCode)
? "Activité"
: query.ActivityCode,
};
var performer = new ActivityUserDisplayItem
{
PerformerId = query.PerformerId,
UserName = "Mon profil fournisseur",
AvatarFallbackLabel = "M",
IsPerformerActive = true,
PerformerStatusBadgeLabel = "Actif",
PerformerStatusBadgeBackground = "#E6F7EC",
PerformerStatusBadgeBorder = "#2E7D32",
PerformerStatusBadgeForeground = "#1B5E20",
};
var form = new CommandFormSummary
{
ActionName = query.BillingCode,
Title = query.BillingCode,
};
return (activity, performer, form);
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
@ -8,11 +9,13 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using AvaloniaEdit.Document;
namespace PostIt.ViewModels;
public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -34,8 +37,19 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary>
public string DraftArticle
{
get => DraftArticleDocument.Text;
set {
OnPropertyChanging(nameof(DraftArticle));
DraftArticleDocument = new TextDocument(value);
this.OnPropertyChanged(nameof(DraftArticle));
}
}
[ObservableProperty]
public partial string DraftArticle { get; set; }
public partial TextDocument DraftArticleDocument {get; set;}
/// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the
@ -66,6 +80,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
[ObservableProperty]
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
[ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; }
@ -75,6 +92,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
[ObservableProperty]
public partial Settings Settings { get; private set; }
[ObservableProperty]
public partial bool IsMarkdownViewerVisible { get; set; }
[RelayCommand]
internal async Task RefreshAsync()
{
@ -91,12 +111,6 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
});
}
[RelayCommand]
internal async Task SearchAsync() {
await RefreshAsync();
ApplyFilter();
}
[RelayCommand]
internal async Task SaveAsync()
{
@ -113,9 +127,11 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
await ExecuteAsync(async () =>
{
var attachments = DraftAttachments.ToArray();
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// place. The previous behavior copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
@ -133,15 +149,33 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished
};
var created = await BlogClient!.CreatePostAsync(draft);
var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null)
{
SelectedPost = created;
if (TryAppendAttachmentLinks(created, attachments))
{
var linkUpdate = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
}
this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear();
}
}
else
{
TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto
{
Id = SelectedPost.Id,
@ -152,8 +186,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear();
}
await RefreshPostsAsync();
@ -336,8 +371,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
}
public MainViewModel()
public BlogsViewModel()
{
WindowTitle = "Blog";
SettingsModel = new Settings();
Init(SettingsModel);
BlogClient = null;
@ -347,14 +383,17 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
{
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>();
SelectedPost = null;
IsBusy = false;
this.SetInfoStatus("Prêt.");
Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
IsMarkdownViewerVisible = false;
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
IsLoaded = false;
// Production path: DI injects the canonical Settings singleton
@ -396,8 +435,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{
WindowTitle = "Blog";
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
@ -427,6 +467,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
// Mirror publication state too. Defaults to false on
// null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear();
UpdateCommandStates();
}
@ -436,7 +477,6 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
// enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
private async Task RefreshPostsAsync()
@ -508,4 +548,55 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
IsLoaded = true;
}
}
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
{
if (attachments.Count == 0)
return false;
var ownerSegment = post.Author?.UserName;
if (string.IsNullOrWhiteSpace(ownerSegment))
ownerSegment = post.AuthorId;
if (string.IsNullOrWhiteSpace(ownerSegment))
return false;
var article = DraftArticle ?? string.Empty;
var links = new List<string>();
foreach (var attachment in attachments)
{
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
var fileUrl = ResolveUserFileUrl(relativePath);
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
links.Add(markdownLine);
}
if (links.Count == 0)
return false;
var prefix = article.Length == 0
? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + string.Join("\n", links);
return true;
}
private string ResolveUserFileUrl(string relativePath)
{
var authority = Settings?.Authentication?.Authority;
if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
{
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
}
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
}
private static string EscapePathSegment(string segment)
=> Uri.EscapeDataString(segment);
}

View file

@ -12,6 +12,9 @@ namespace PostIt.ViewModels.Commands;
public partial class RdvViewModel : BillingCommandPageViewModel
{
private long? _existingLocationId;
private bool _hydratingExistingQuery;
public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande.";
[ObservableProperty]
@ -56,6 +59,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
_hydratingExistingQuery = true;
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
@ -70,11 +74,18 @@ public partial class RdvViewModel : BillingCommandPageViewModel
if (existingQuery.Location is not null)
{
_existingLocationId = existingQuery.Location.Id;
Address = existingQuery.Location.Address ?? string.Empty;
SuggestedAddress = string.Empty;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
else
{
_existingLocationId = null;
}
_hydratingExistingQuery = false;
this.SetInfoStatus($"Commande #{existingQuery.Id} chargée.");
}
@ -117,20 +128,22 @@ public partial class RdvViewModel : BillingCommandPageViewModel
}
}
protected static object BuildLocationPayload(string address, double? latitude, double? longitude)
protected static BillingLocationDto BuildLocationPayload(string address, double? latitude, double? longitude, long? locationId = null)
{
if (latitude.HasValue && longitude.HasValue)
{
return new
return new BillingLocationDto
{
Id = locationId,
Address = address,
Latitude = latitude.Value,
Longitude = longitude.Value,
};
}
return new
return new BillingLocationDto
{
Id = locationId,
Address = address,
};
}
@ -223,6 +236,30 @@ public partial class RdvViewModel : BillingCommandPageViewModel
OnPropertyChanged(nameof(EventDateSelection));
}
partial void OnAddressChanged(string value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
partial void OnLatitudeChanged(double? value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
partial void OnLongitudeChanged(double? value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
protected override async Task SubmitAsync()
{
@ -257,7 +294,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var locationPayload = BuildLocationPayload(address, Latitude, Longitude, IsEditingExisting ? _existingLocationId : null);
var payload = new BillingQueryDetailsDto
{
@ -270,12 +307,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
Location = locationPayload
};
if (IsEditingExisting)

View file

@ -1,64 +0,0 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
{
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
{
get => _welcomeText;
set => SetProperty(ref _welcomeText, value);
}
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
}
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync);
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
/// render without a running App. Production paths always reach
/// the parameterised constructor (DI or direct injection), and
/// the postit://callback crash is fixed at the Settings layer
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
{
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
{
get => _welcomeText;
set => SetProperty(ref _welcomeText, value);
}
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
}
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
private async Task OpenProviderRequestsAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingClient = app.ServiceProvider?.GetRequiredService<BillingApiClient>();
if (billingClient is null)
{
throw new InvalidOperationException("Client billing indisponible.");
}
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenEstimateListAsync(EstimateListPerspective perspective)
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
if (estimateClient is null)
{
throw new InvalidOperationException("Client devis indisponible.");
}
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
if (frontClient is null)
{
throw new InvalidOperationException("Client front-office indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
/// render without a running App. Production paths always reach
/// the parameterised constructor (DI or direct injection), and
/// the postit://callback crash is fixed at the Settings layer
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{
}
}

View file

@ -23,8 +23,8 @@ public sealed class StatusNotice
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#FDECEA", "#C62828", "#7F1D1D"),
StatusSeverity.Warning => ("~", "#FFF8E1", "#E6A700", "#7C4A03"),
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
}

View file

@ -22,7 +22,7 @@ public partial class AuthenticationSettings : ObservableObject
public const string DefaultClientId = "postit";
public static readonly string[] DefaultScopes = { "blogs" };
public static readonly string[] DefaultScopes = { "blogs", "api" };
[ObservableProperty]
public partial string Authority { get; set; }

View file

@ -15,7 +15,8 @@ namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
[JsonIgnore]
public string? SettingsFileFullName { get; private set; }
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
@ -32,10 +33,70 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsMarkdownViewerVisible { get; set; } = false;
[ObservableProperty]
public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty;
[ObservableProperty]
[JsonIgnore]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
public bool Loaded { get; private set; } = false;
/// <summary>
/// True when the in-memory state has drifted from the last
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
/// this flag, so it only enables when the user has actually
/// touched something since the last load / save. Cleared by
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
/// every successful setter on the four top-level mutable
/// properties and on the sub-properties of
/// <see cref="Authentication"/>.
/// </summary>
[ObservableProperty]
public partial bool IsDirty { get; private set; } = false;
/// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
/// generates setters that call <c>SetProperty(...)</c> which fires
/// <c>PropertyChanged</c>. Avalonia bindings consume that event on
/// the UI thread, and a stray background-thread update is exactly
/// what crashed <c>DataValidationErrors.SetErrors</c> on
/// <c>postit://callback</c> re-launches. The lock makes mutations
/// atomic; <see cref="OnPropertyChanged(PropertyChangedEventArgs)"/>
/// then marshals the notification onto the UI thread so bindings
/// observe the change on the right thread.
/// </summary>
private readonly object _mutationGate = new();
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
/// about the contract: a missing scope here surfaces as a 401
/// on the very first API call after login, with no obvious link
/// to the settings. The "feature" scopes the user must opt into
/// (e.g. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
private static readonly string[] BuiltInScopes = new[]
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access", // OIDC: required to receive a refresh_token
"blogs",
"api"
};
private readonly string DEFAULT_SETTINGS_FILENAME = "postit-settings.json";
public void SetActionStatus(string message, StatusSeverity severity = StatusSeverity.Info)
{
ActionStatus = severity switch
@ -62,6 +123,7 @@ public partial class Settings : ViewModelBase
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -82,35 +144,6 @@ public partial class Settings : ViewModelBase
MarkDirty();
}
public bool Loaded { get; private set; } = false;
/// <summary>
/// True when the in-memory state has drifted from the last
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
/// this flag, so it only enables when the user has actually
/// touched something since the last load / save. Cleared by
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
/// every successful setter on the four top-level mutable
/// properties and on the sub-properties of
/// <see cref="Authentication"/>.
/// </summary>
[ObservableProperty]
public partial bool IsDirty { get; private set; } = false;
/// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
/// generates setters that call <c>SetProperty(...)</c> which fires
/// <c>PropertyChanged</c>. Avalonia bindings consume that event on
/// the UI thread, and a stray background-thread update is exactly
/// what crashed <c>DataValidationErrors.SetErrors</c> on
/// <c>postit://callback</c> re-launches. The lock makes mutations
/// atomic; <see cref="OnPropertyChanged(PropertyChangedEventArgs)"/>
/// then marshals the notification onto the UI thread so bindings
/// observe the change on the right thread.
/// </summary>
private readonly object _mutationGate = new();
/// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE
/// (no client secret). The browser implementation should be supplied
@ -174,27 +207,6 @@ public partial class Settings : ViewModelBase
Authentication.RefreshScopeListText();
}
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
/// about the contract: a missing scope here surfaces as a 401
/// on the very first API call after login, with no obvious link
/// to the settings. The "feature" scopes the user must opt into
/// (e.g. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
private static readonly string[] BuiltInScopes = new[]
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access", // OIDC: required to receive a refresh_token
"blogs",
"api"
};
/// <summary>
/// Merge user-configured scopes with the built-in ones. User scopes
@ -248,15 +260,37 @@ public partial class Settings : ViewModelBase
}
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
Directory.CreateDirectory(configDir);
var app = App.Current as App;
if (app!=null)
if (app.CliConfigFileSpecification!=null)
{
SettingsFileFullName = app.CliConfigFileSpecification;
}
string configPath = Path.Combine(configDir, SettingsFileName);
if (SettingsFileFullName is not null)
{
// Already set by a previous Load() or by the environment
// variable path above. Use it as-is.
}
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
&& !string.IsNullOrWhiteSpace(envPath))
{
FileInfo configByEnvFileInfo = new FileInfo(envPath);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
SettingsFileFullName = configByEnvFileInfo.FullName;
}
else
{
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
}
FileInfo configFileInfo = new FileInfo(configPath);
FileInfo configFileInfo = new FileInfo(SettingsFileFullName);
if (!configFileInfo.Exists)
{
@ -285,6 +319,7 @@ public partial class Settings : ViewModelBase
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
ApplyJson(json, $"user file {configFileInfo.FullName}");
SettingsFileFullName = configFileInfo.FullName;
Loaded = true;
}
catch (Exception ex)
@ -336,7 +371,7 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate)
{
var legacyApiUrl = TryReadLegacyApiUrl(json);
var legacyApiUrl = TryReadApiUrl(json);
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
@ -346,6 +381,7 @@ public partial class Settings : ViewModelBase
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
@ -391,7 +427,7 @@ public partial class Settings : ViewModelBase
}
}
private static string? TryReadLegacyApiUrl(string json)
private static string? TryReadApiUrl(string json)
{
try
{
@ -424,6 +460,7 @@ public partial class Settings : ViewModelBase
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
this.ProviderOngoingRequestsSortOption = string.Empty;
}
/// <summary>
@ -446,11 +483,17 @@ public partial class Settings : ViewModelBase
{
SetActionStatus("Enregistrement des parametres...", StatusSeverity.Info);
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
Directory.CreateDirectory(configDir);
var configPath = Path.Combine(configDir, SettingsFileName);
if (SettingsFileFullName is null)
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
Directory.CreateDirectory(configDir);
SettingsFileFullName = Path.Combine(configDir, DEFAULT_SETTINGS_FILENAME);
}
var configPath = SettingsFileFullName!;
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
lock (_mutationGate)
{
@ -466,7 +509,7 @@ public partial class Settings : ViewModelBase
UnixFileMode.UserRead | UnixFileMode.UserWrite);
IsDirty = false;
SetActionStatus("Parametres sauvegardes.", StatusSeverity.Info);
Console.WriteLine($"💾 Settings saved to {configPath}");
}
catch (Exception ex)
@ -489,6 +532,7 @@ public partial class Settings : ViewModelBase
/// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

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

View file

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

View file

@ -34,13 +34,13 @@
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,12,*">
<Button Grid.Column="0"
Content="Ouvrir le formulaire"
Content="Ouvrir le formulaire de demande"
Command="{Binding OpenSelectedFormCommand}" />
<Button Grid.Column="2"
Content="Voir les commandes"
Content="Voir les demandes non validées"
Command="{Binding OpenQueriesCommand}" />
<Button Grid.Column="4"
Content="Demandes en cours (lecture seule)"
Content="Demandes en cours (validées, en lecture seule)"
Command="{Binding OpenOngoingQueriesCommand}" />
<postitControls:StatusBar Grid.Column="6"
DataContext="{Binding ActionStatus}" />

View file

@ -0,0 +1,151 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel"
Header="Edition de devis">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="Demande"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Text="{Binding QueryDescription}"
TextWrapping="Wrap"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="Titre"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBox Grid.Row="1"
Grid.Column="2"
Text="{Binding EstimateTitle}"
PlaceholderText="Titre du devis" />
<TextBlock Grid.Row="2"
Grid.Column="0"
Text="Description"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBox Grid.Row="2"
Grid.Column="2"
Text="{Binding EstimateDescription}"
PlaceholderText="Description détaillée du devis"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="70" />
</Grid>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="Lignes du devis"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter une ligne"
Command="{Binding AddLineCommand}" />
<Button Grid.Column="3"
Content="Retirer la ligne"
Command="{Binding RemoveLineCommand}" />
</Grid>
<ListBox ItemsSource="{Binding Lines}"
SelectedItem="{Binding SelectedLine, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EstimateLineItemViewModel">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,8">
<Grid ColumnDefinitions="*,8,110,8,130,8,90"
RowDefinitions="Auto,Auto"
RowSpacing="6">
<TextBox Grid.Row="0"
Grid.Column="0"
Text="{Binding Name}"
PlaceholderText="Nom de la ligne" />
<NumericUpDown Grid.Row="0"
Grid.Column="2"
Value="{Binding Count}"
Minimum="1"
Increment="1"
FormatString="0" />
<NumericUpDown Grid.Row="0"
Grid.Column="4"
Value="{Binding UnitaryCost}"
Increment="0.5"
FormatString="0.00" />
<TextBlock Grid.Row="0"
Grid.Column="6"
Text="{Binding LineTotalLabel}"
VerticalAlignment="Center"
HorizontalAlignment="Right"
FontWeight="SemiBold" />
<TextBox Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="7"
Text="{Binding Description}"
PlaceholderText="Description de la ligne" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="1"
Text="{Binding TotalLabel, StringFormat='Total : {0}'}"
FontSize="16"
FontWeight="Bold"
HorizontalAlignment="Right" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<Button Grid.Column="0"
Content="{Binding SendLabel}"
Command="{Binding SendCommand}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class EstimateEditionPage : ContentPage
{
public EstimateEditionPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,77 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateListPage"
x:DataType="vm:EstimateListPageViewModel"
Header="{Binding Title}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
<TextBlock Text="{Binding Subtitle}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBox Grid.Column="2"
PlaceholderText="Filtrer: titre, description, type, partie..."
Text="{Binding FilterText, Mode=TwoWay}" />
</Grid>
<ListBox Grid.Row="2"
ItemsSource="{Binding Estimates}"
SelectedItem="{Binding SelectedEstimate, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dto:EstimateDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<StackPanel Spacing="8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Title}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Id}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding Description}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding Bill.Count}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
<Button Content="Ouvrir"
HorizontalAlignment="Right"
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,102 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.ProviderOngoingRequestsPage"
x:DataType="vm:ProviderOngoingRequestsPageViewModel"
Header="Mes demandes en cours">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
<TextBlock Text="Point de vue fournisseur" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*,8,Auto" Margin="0,10,0,8">
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<ComboBox Grid.Column="2"
Width="220"
ItemsSource="{Binding SortOptions}"
SelectedItem="{Binding SelectedSortOption, Mode=TwoWay}" />
<TextBox Grid.Column="4"
PlaceholderText="Filtrer: activité, code, client, statut..."
Text="{Binding FilterText, Mode=TwoWay}" />
<TextBlock Grid.Column="6"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Text="{Binding Queries.Count, StringFormat='Résultats : {0}'}"
Opacity="0.7" />
</Grid>
<TextBlock Grid.Row="2"
Text="Astuce: sélectionnez une ligne puis ouvrez le détail ou l'édition directe."
Opacity="0.65"
FontSize="11"
Margin="0,0,0,8" />
<Grid Grid.Row="3" RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Queries}"
SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dto:BillingQuerySummaryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Description}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Status}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding ActivityCode, StringFormat='Activité : {0}'}"
FontSize="11"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding BillingCode, StringFormat='Code : {0}'}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,8,0,0"
Spacing="8">
<Button Content="Ouvrir le détail"
Command="{Binding OpenSelectedQueryCommand}" />
<Button Content="Ouvrir en édition"
Command="{Binding OpenSelectedEditorCommand}"
/>
<Button Content="Créer un devis"
Command="{Binding CreateEstimateForSelectedCommand}"
/>
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ProviderOngoingRequestsPage : ContentPage
{
public ProviderOngoingRequestsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,165 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:md="using:MarkdownViewer.Core.Controls"
mc:Ignorable="d"
x:Class="PostIt.Views.Blogs.BlogsPage"
x:DataType="vm:BlogsViewModel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer
VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
>
<StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
<TextBox HorizontalAlignment="Right"
Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
</StackPanel>
<CommandBar IsDynamicOverflowEnabled="True"
HorizontalAlignment="Right" >
<CommandBar.PrimaryCommands>
<CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
x:Name="AddAttachmentButton">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="ManageAclButton"
Label="ACL"
Command="{Binding ManageAclAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="OpenCirclesButton"
Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton Label="Publié"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton Label="Markdown Viewer"
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton x:Name="OpenSignatureDevButton"
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}"
SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4" >
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
IsVisible="{Binding IsMarkdownViewerVisible}"
MarkdownText="{Binding DraftArticle}" />
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
IsVisible="{Binding !IsMarkdownViewerVisible}">
<Grid RowSpacing="10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox x:Name="DraftTitleTextBox"
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<AvaloniaEdit:TextEditor
Grid.Row="2" VerticalAlignment="Stretch"
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage
{
public BlogsPage()
{
InitializeComponent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
private async void AddAttachment_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not ViewModels.BlogsViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à joindre au billet",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new System.Collections.Generic.List<Yavsc.Api.Client.BlogUploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
vm.DraftAttachments.Clear();
foreach (var upload in uploads)
vm.DraftAttachments.Add(upload);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

Some files were not shown because too many files have changed in this diff Show more