diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml
index ea58d2fef..67b743fb5 100644
--- a/.forgejo/workflows/buildAndTest.yml
+++ b/.forgejo/workflows/buildAndTest.yml
@@ -19,18 +19,19 @@ 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
cd _src
@@ -40,12 +41,17 @@ 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 ($?)!"
+ --filter="Category!=Platform-Android"
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
index a72f92bd6..7b17bba42 100644
--- a/.forgejo/workflows/release.yml
+++ b/.forgejo/workflows/release.yml
@@ -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:
@@ -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
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 000000000..056cf69d2
--- /dev/null
+++ b/.gitleaksignore
@@ -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
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
index dc8d3c68d..d5088f01d 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -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",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index a30c9c065..71384cd8c 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -47,7 +47,10 @@
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
- "isBackground": true
+ "isBackground": true,
+ "options": {
+ "cwd": "${workspaceFolder}"
+ }
},
{
"label": "test blogs backend",
@@ -63,6 +66,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 +101,21 @@
"kind": "build"
},
"isBackground": true
+ },
+ {
+ "label": "dotnet: build-postit-desktop",
+ "type": "process",
+ "isBuildCommand": true,
+ "isTestCommand": false,
+ "isBackground": true,
+ "command": "dotnet",
+ "args": ["build", "/property:GenerateFullPaths=true"],
+ "options": {
+ "cwd": "src/PostIt/PostIt.Desktop"
+ },
+ "group": {
+ "kind": "build"
+ }
}
]
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 111c1491a..3f8253c70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,45 @@
# Changelog
+## [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
diff --git a/Directory.Build.props b/Directory.Build.props
index 83d21579d..bf1bf8bcb 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -2,5 +2,16 @@
Yavsc
NU1701, NU1901, NU1902, NU1507
+
+ false
diff --git a/Makefile b/Makefile
index 08f0461d2..d99fe5e5e 100644
--- a/Makefile
+++ b/Makefile
@@ -48,70 +48,4 @@ docker-build:
docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc
-# Crée une branche release/ depuis main, met à jour les
-# `` 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="; \
- 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
diff --git a/contrib/Makefile b/contrib/Makefile
index 79145668f..4e4ef4df6 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -95,6 +95,15 @@ showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
+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
diff --git a/contrib/README.md b/contrib/README.md
deleted file mode 100644
index 011561560..000000000
--- a/contrib/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Read me
-
-## Note aux icones
-
-㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩
diff --git a/dotnet-tools.json b/dotnet-tools.json
index b0e38abda..1762a89de 100644
--- a/dotnet-tools.json
+++ b/dotnet-tools.json
@@ -1,5 +1,13 @@
{
"version": 1,
"isRoot": true,
- "tools": {}
+ "tools": {
+ "picket": {
+ "version": "0.2.12",
+ "commands": [
+ "picket"
+ ],
+ "rollForward": false
+ }
+ }
}
\ No newline at end of file
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index 2f27340e9..e8804a8bf 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -1,35 +1,31 @@
-
+
-
- true
- 12.1.1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ true
+ 12.1.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj
index 3d8be385c..a6d04cb33 100644
--- a/src/PostIt/PostIt.Android/PostIt.Android.csproj
+++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj
@@ -11,8 +11,8 @@
false
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
index 4534a2940..ff2f3f2b8 100644
--- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
+++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
@@ -6,8 +6,8 @@
enable
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
index 948e726c5..36ed1251e 100644
--- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
+++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
@@ -7,8 +7,8 @@
enable
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
app.manifest
diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs
index 04cc0f314..6cac39603 100644
--- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs
@@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
return Task.CompletedTask;
}
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs
index 17fcc968c..34804b4cd 100644
--- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs
@@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
return Task.CompletedTask;
}
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs
index a7f2b0844..e53139351 100644
--- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs
@@ -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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
index 4f102be20..112c11752 100644
--- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
+++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
@@ -62,4 +62,17 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
});
return Task.FromResult(default(T)!);
}
+
+ /// Multipart overload: BlogApiClient routes
+ /// Create/UpdatePostAsync with attachments through here.
+ /// Without this override the call would fall through to the
+ /// base 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.
+ public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ {
+ _recorder.Calls.Add((method, path, contentFactory));
+ return Task.FromResult(default(T)!);
+ }
}
diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
new file mode 100644
index 000000000..946d3d28e
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
@@ -0,0 +1,79 @@
+using Yavsc.Blogspot;
+using Yavsc.Api.Client;
+using PostIt.ViewModels;
+
+namespace PostIt.Tests;
+
+///
+/// Pins the markdown link that
+/// appends to the article after a successful attachment upload.
+///
+/// The link must be an absolute URL derived from the OIDC
+/// authority via FileServerUrlHelpers.GetUserFilesUri
+/// (Yavsc.Abstract): the /files file server is hosted by
+/// Yavsc.Org — the IdP — not by the blogs API host, so a link
+/// built from BlogsApiUrl (or left relative) 404s when the
+/// reader opens the post. The regression this guards: a generated
+/// link of the shape /files/{user}/blogs/{id}/{name}
+/// (relative, authority-less) instead of
+/// https://{authority}/files/{user}/blogs/{id}/{name}.
+///
+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>(linkPut.body);
+ var multipartContent = Assert.IsType(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);
+ }
+}
diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
new file mode 100644
index 000000000..db70e25fa
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
@@ -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(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(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 CallAsync(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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
new file mode 100644
index 000000000..6158b4132
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
@@ -0,0 +1,134 @@
+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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
+
+ await vm.InitializeAsync();
+
+ Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
+ Assert.Equal("Mes devis à valider", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
+
+ await vm.InitializeAsync();
+
+ Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
+ Assert.Equal("Mes devis en attente", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
+
+ 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
+
+ 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 Paths { get; } = new();
+ public bool Empty { get; init; }
+
+ public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
+ {
+ Paths.Add(path);
+
+ if (typeof(T) == typeof(List))
+ {
+ var items = Empty
+ ? new List()
+ : new List
+ {
+ new()
+ {
+ Id = 1,
+ Title = "Devis plomberie",
+ Description = "Réparation fuite",
+ ClientId = "bob",
+ OwnerId = "alice",
+ CommandType = "Rdv",
+ Bill = new List
+ {
+ 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(),
+ },
+ };
+ 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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs
new file mode 100644
index 000000000..16f01c5e9
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs
@@ -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));
+ }
+}
diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs
index d1d00532d..f72c8d4d5 100644
--- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs
+++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs
@@ -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();
services.AddTransient();
services.AddTransient();
- 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
///
/// Mount a real (as
/// SessionStatusBannerTests does), push a
- /// with the given VM onto
+ /// with the given VM onto
/// NavRoot. PushAsync is awaited (via
/// GetAwaiter().GetResult()) 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 KeyPressQwerty has a real
/// to dispatch against.
///
- 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
diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs
index 0fd5627c8..519cd141d 100644
--- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs
+++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs
@@ -5,10 +5,11 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.ViewModels;
using PostIt.Views;
+using PostIt.Views.Blogs;
namespace PostIt.Tests;
///
-/// Headless UI tests for the "Save" flow in .
+/// Headless UI tests for the "Save" flow in .
/// The pattern is the one SessionStatusBannerTests
/// established: [AvaloniaFact], a
/// hosting the page (via a 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
diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs
index 1a867bd61..8ecc5bb55 100644
--- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs
@@ -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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
diff --git a/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs
new file mode 100644
index 000000000..cfb300316
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs
@@ -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 Paths { get; } = new();
+
+ public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
+ {
+ Paths.Add(path);
+
+ if (typeof(T) == typeof(List))
+ {
+ var data = new List
+ {
+ 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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs
index fdf8689ab..1bcb8a0d8 100644
--- a/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs
+++ b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs
@@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => CallAsync(method, path, (object?)null, ct);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index 59d5e412c..b80351e17 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -46,35 +46,48 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- var window = ServiceProvider.GetRequiredService();
- 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();
- this.ConfigureRootView(View);
- ApplyDarkMode(settings);
- return View;
- };
+ InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
- singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService();
- 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();
+ ConfigureRootView(View);
+ ApplyDarkMode(settings);
+ }
+
+ public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
+ {
+ singleViewFactoryApplicationLifetime.MainViewFactory =
+ () =>
+ {
+ this.ConfigureRootView(ServiceProvider!.GetRequiredService());
+ ApplyDarkMode(settings);
+ return View!;
+ };
+ }
+
+ public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var window = ServiceProvider!.GetRequiredService();
+ 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 +100,7 @@ private void ConfigureRootView(MainView rootView)
};
rootView.SessionBanner.DataContext = sessionStatus;
+ this.View = rootView;
}
private async Task BootOnceAsync()
@@ -148,7 +162,7 @@ private void ConfigureRootView(MainView rootView)
public static async Task PushBlogsPageAsync()
{
var app = (App)Current!;
- var mainVm = app.ServiceProvider!.GetRequiredService();
+ var mainVm = app.ServiceProvider!.GetRequiredService();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm);
}
diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
index c0831b290..bc5e7298f 100644
--- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
+++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
@@ -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,13 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
+ var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// SettingsPage is a singleton: there must be one and only one
@@ -58,6 +60,10 @@ public static class ServiceCollectionHelpers
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
// ViewModels
services.AddSingleton(settings);
services.AddSingleton(api);
@@ -67,6 +73,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
+ services.AddSingleton(estimateClient);
services.AddSingleton(reverseGeocoding);
services.AddSingleton(userDirectory);
services.AddSingleton();
@@ -89,7 +96,7 @@ public static class ServiceCollectionHelpers
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
return services.BuildServiceProvider();
}
}
diff --git a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
index e4250cbb9..f30e021d9 100644
--- a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
+++ b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
@@ -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 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;
}
}
diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj
index 09632bdf8..a6f01cc31 100644
--- a/src/PostIt/PostIt/PostIt.csproj
+++ b/src/PostIt/PostIt/PostIt.csproj
@@ -5,8 +5,8 @@
latest
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
@@ -33,6 +33,7 @@
+
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
index 4d748f87d..04dbce1fd 100644
--- a/src/PostIt/PostIt/Services/YavscApiClient.cs
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -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(stream,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
- return dto!;
+ return await ReadJsonAsync(response, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Call a multipart endpoint, transparently refreshing the token if needed.
+ ///
+ public virtual async Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func 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(response, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deserialise the response body as ,
+ /// tolerating an empty body. PUT /blogspot/{id} answers
+ /// 204 No Content: without this guard,
+ /// JsonSerializer.DeserializeAsync throws
+ /// JsonException: The input does not contain any JSON
+ /// tokens and the VM surfaces a successful save as an
+ /// error. Callers that genuinely need a payload
+ /// (GetPostAsync, CreatePostAsync) are unaffected:
+ /// their endpoints always return a body.
+ ///
+ private static async Task ReadJsonAsync(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(payload,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
///
@@ -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);
+ }
+
+ ///
+ /// Call a multipart endpoint that returns no useful body (DELETE, etc.).
+ ///
+ public async Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func 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 SendAsync(
- HttpMethod method, string path, object? body, CancellationToken ct)
+ HttpMethod method, string path, Func? 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);
}
diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs
index f866e1bac..11797b714 100644
--- a/src/PostIt/PostIt/ViewLocator.cs
+++ b/src/PostIt/PostIt/ViewLocator.cs
@@ -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(),
+ BlogsViewModel => services.GetRequiredService(),
Settings => services.GetRequiredService(),
HomePageViewModel => services.GetRequiredService(),
ActivitiesPageViewModel => services.GetRequiredService(),
@@ -51,6 +52,9 @@ public class ViewLocator : IDataTemplate
PostAclDialogViewModel => services.GetRequiredService(),
BillingQueriesPageViewModel => services.GetRequiredService(),
BillingQueryDetailsPageViewModel => services.GetRequiredService(),
+ ProviderOngoingRequestsPageViewModel => services.GetRequiredService(),
+ EstimateEditionPageViewModel => services.GetRequiredService(),
+ EstimateListPageViewModel => services.GetRequiredService(),
null => new TextBlock { Text = "No view for " },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
diff --git a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/AddCircleMemberDialogViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs
rename to src/PostIt/PostIt/ViewModels/ACL/AddCircleMemberDialogViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
rename to src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs b/src/PostIt/PostIt/ViewModels/Activity/ActivityUserDisplayItem.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs
rename to src/PostIt/PostIt/ViewModels/Activity/ActivityUserDisplayItem.cs
diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
similarity index 97%
rename from src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
index bccf97167..aab923d80 100644
--- a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
@@ -43,7 +43,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 +75,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()
diff --git a/src/PostIt/PostIt/ViewModels/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/BillingQueryDetailsPageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/BillingQueryDisplayItem.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDisplayItem.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/BillingQueryDisplayItem.cs
rename to src/PostIt/PostIt/ViewModels/Activity/BillingQueryDisplayItem.cs
diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
new file mode 100644
index 000000000..8f007dc08
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
@@ -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;
+
+///
+/// Edition d'un devis (Estimate) créé en réponse à une demande
+/// client () consultée depuis la
+/// page « Mes demandes en cours ». L'envoi poste le devis sur
+/// api/v1/estimate; côté serveur, la commande liée
+/// () est alors marquée comme
+/// validée par le prestataire.
+///
+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 Lines { get; set; } = new();
+
+ [ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))]
+ public partial EstimateLineItemViewModel? SelectedLine { get; set; }
+
+ [ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
+ public partial bool IsBusy { get; set; }
+
+ ///
+ /// True une fois le devis accepté par le serveur: l'envoi est
+ /// désactivé pour éviter les doublons, il ne reste que « Retour ».
+ ///
+ [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())
+ {
+ item.PropertyChanged -= OnLinePropertyChanged;
+ }
+ }
+
+ if (e.NewItems is not null)
+ {
+ foreach (var item in e.NewItems.OfType())
+ {
+ 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));
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs
new file mode 100644
index 000000000..864906cdd
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs
@@ -0,0 +1,35 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace PostIt.ViewModels;
+
+///
+/// Editable estimate line. is exposed as a
+/// so it binds directly to
+/// NumericUpDown.Value (decimal?); it is rounded back
+/// to an integer when the DTO is built.
+///
+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}";
+}
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
new file mode 100644
index 000000000..e5c4d355f
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
@@ -0,0 +1,156 @@
+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 CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PostIt.Helpers;
+using Yavsc.Api.Client;
+
+namespace PostIt.ViewModels;
+
+///
+/// Point de vue d'une liste de devis en cours (non encore validés par
+/// le client).
+///
+public enum EstimateListPerspective
+{
+ /// « Mes devis à valider » — l'utilisateur courant est le client.
+ Client,
+
+ /// « Mes devis en attente » — l'utilisateur courant est le fournisseur.
+ Provider,
+}
+
+///
+/// Liste des devis en cours servie par GET api/v1/estimate/asclient
+/// ou GET api/v1/estimate/asprovider selon la perspective choisie.
+///
+public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
+{
+ private readonly EstimateApiClient _estimateClient;
+ private readonly EstimateListPerspective _perspective;
+ private List _allEstimates = new();
+
+ public EstimateListPerspective Perspective => _perspective;
+
+ public string Title => _perspective == EstimateListPerspective.Client
+ ? "Mes devis à valider"
+ : "Mes devis en attente";
+
+ public string Subtitle => _perspective == EstimateListPerspective.Client
+ ? "Point de vue client"
+ : "Point de vue fournisseur";
+
+ ///
+ /// Libellé de la colonne « autre partie » : côté client on montre
+ /// le fournisseur (OwnerId), côté fournisseur on montre le client.
+ ///
+ public string CounterpartLabel => _perspective == EstimateListPerspective.Client
+ ? "Fournisseur"
+ : "Client";
+
+ [ObservableProperty]
+ public partial ObservableCollection 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)
+ {
+ _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
+ _perspective = perspective;
+ }
+
+ 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();
+
+ _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();
+ Estimates = new ObservableCollection();
+ this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous.");
+ }
+ catch (Exception ex)
+ {
+ _allEstimates = new List();
+ Estimates = new ObservableCollection();
+ 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(filtered);
+ }
+
+ private static bool ContainsInsensitive(string? value, string query)
+ => !string.IsNullOrWhiteSpace(value)
+ && value.Contains(query, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
new file mode 100644
index 000000000..71e979e66
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
@@ -0,0 +1,390 @@
+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 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 _allQueries = new();
+
+ [ObservableProperty]
+ public partial ObservableCollection Queries { get; set; } = new();
+
+ [ObservableProperty]
+ public partial string FilterText { get; set; } = string.Empty;
+
+ public IReadOnlyList 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();
+ Queries = new ObservableCollection();
+ this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
+ }
+ catch (Exception ex)
+ {
+ _allQueries = new List();
+ Queries = new ObservableCollection();
+ 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 vm = new BillingQueryDetailsPageViewModel(
+ activity,
+ performer,
+ form,
+ _billingClient,
+ 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(sorted);
+ }
+
+ private List ApplySort(IEnumerable 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.Accepted => 1,
+ QueryStatus.Inserted => 2,
+ QueryStatus.Success => 3,
+ QueryStatus.Rejected => 4,
+ QueryStatus.Failed => 5,
+ _ => 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);
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/SelectableHairPrestationItem.cs b/src/PostIt/PostIt/ViewModels/Activity/SelectableHairPrestationItem.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/SelectableHairPrestationItem.cs
rename to src/PostIt/PostIt/ViewModels/Activity/SelectableHairPrestationItem.cs
diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
similarity index 84%
rename from src/PostIt/PostIt/ViewModels/MainViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
index d2667c012..726129f12 100644
--- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
@@ -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,12 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
+using Yavsc.Abstract.Files;
using PostIt.Helpers;
namespace PostIt.ViewModels;
-public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
+public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{
/// Window/tab title. Cosmetic — bound by
/// MainPage.axaml if at all. Not the post title.
@@ -66,6 +68,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
[ObservableProperty]
public partial ObservableCollection FilteredPosts { get; set; }
+ [ObservableProperty]
+ public partial ObservableCollection DraftAttachments { get; set; }
+
[ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; }
@@ -75,6 +80,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 +99,6 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
});
}
- [RelayCommand]
- internal async Task SearchAsync() {
- await RefreshAsync();
- ApplyFilter();
- }
-
[RelayCommand]
internal async Task SaveAsync()
{
@@ -113,9 +115,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 +137,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 +174,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 +359,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
}
- public MainViewModel()
+ public BlogsViewModel()
{
+ WindowTitle = "Blog";
SettingsModel = new Settings();
Init(SettingsModel);
BlogClient = null;
@@ -347,11 +371,13 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
{
Posts = new ObservableCollection();
FilteredPosts = new ObservableCollection();
+ DraftAttachments = new ObservableCollection();
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;
@@ -396,8 +422,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
/// . Production code uses the
/// (Settings, BlogApiClient) overload below.
///
- 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 +454,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();
}
@@ -508,4 +536,55 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
IsLoaded = true;
}
}
+
+ private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection 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();
+
+ 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);
}
diff --git a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs
index 7a7d17df6..4f9d457a5 100644
--- a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs
@@ -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)
diff --git a/src/PostIt/PostIt/ViewModels/ActionStatusViewModelExtensions.cs b/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/ActionStatusViewModelExtensions.cs
rename to src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
diff --git a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
similarity index 51%
rename from src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
index 830be4dde..ea92f1d0a 100644
--- a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
@@ -5,6 +5,7 @@ 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
@@ -30,10 +31,16 @@ public class HomePageViewModel : ViewModelBase
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; } = 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()
{
@@ -48,6 +55,46 @@ public class HomePageViewModel : ViewModelBase
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();
+ if (billingClient is null)
+ {
+ throw new InvalidOperationException("Client billing indisponible.");
+ }
+
+ var estimateClient = app.ServiceProvider?.GetRequiredService();
+
+ 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();
+ if (estimateClient is null)
+ {
+ throw new InvalidOperationException("Client devis indisponible.");
+ }
+
+ var vm = new EstimateListPageViewModel(estimateClient, perspective);
+ await vm.InitializeAsync();
+ await app.PushPageAsync(vm);
+ }
+
///
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs
diff --git a/src/PostIt/PostIt/ViewModels/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
similarity index 86%
rename from src/PostIt/PostIt/ViewModels/StatusNotice.cs
rename to src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
index 972c64350..c87af390c 100644
--- a/src/PostIt/PostIt/ViewModels/StatusNotice.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
@@ -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"),
};
}
diff --git a/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
index 8034820d9..3c23187e4 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
@@ -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; }
diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index 0864c1416..1c2802e33 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -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;
+
+
+ ///
+ /// True when the in-memory state has drifted from the last
+ /// or snapshot. The
+ /// Settings page binds the Sauver button's IsEnabled to
+ /// this flag, so it only enables when the user has actually
+ /// touched something since the last load / save. Cleared by
+ /// (and by ), set by
+ /// every successful setter on the four top-level mutable
+ /// properties and on the sub-properties of
+ /// .
+ ///
+ [ObservableProperty]
+ public partial bool IsDirty { get; private set; } = false;
+
+
+ ///
+ /// Guards every mutation of the observable state. [ObservableProperty]
+ /// generates setters that call SetProperty(...) which fires
+ /// PropertyChanged. Avalonia bindings consume that event on
+ /// the UI thread, and a stray background-thread update is exactly
+ /// what crashed DataValidationErrors.SetErrors on
+ /// postit://callback re-launches. The lock makes mutations
+ /// atomic;
+ /// then marshals the notification onto the UI thread so bindings
+ /// observe the change on the right thread.
+ ///
+ private readonly object _mutationGate = new();
+
+ ///
+ /// Scopes the PostIt client always requires from the OIDC provider,
+ /// regardless of what the user has in their settings file.
+ ///
+ /// PostIt calls into the Blog API (and any other Yavsc API
+ /// gated by an [Authorize("…Scope")] 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. blogs) are still their choice — we only force the
+ /// structural ones that OIDC itself needs.
+ ///
+ 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();
///
/// Authentication can be reassigned wholesale by
@@ -82,35 +144,6 @@ public partial class Settings : ViewModelBase
MarkDirty();
}
- public bool Loaded { get; private set; } = false;
-
- ///
- /// True when the in-memory state has drifted from the last
- /// or snapshot. The
- /// Settings page binds the Sauver button's IsEnabled to
- /// this flag, so it only enables when the user has actually
- /// touched something since the last load / save. Cleared by
- /// (and by ), set by
- /// every successful setter on the four top-level mutable
- /// properties and on the sub-properties of
- /// .
- ///
- [ObservableProperty]
- public partial bool IsDirty { get; private set; } = false;
-
- ///
- /// Guards every mutation of the observable state. [ObservableProperty]
- /// generates setters that call SetProperty(...) which fires
- /// PropertyChanged. Avalonia bindings consume that event on
- /// the UI thread, and a stray background-thread update is exactly
- /// what crashed DataValidationErrors.SetErrors on
- /// postit://callback re-launches. The lock makes mutations
- /// atomic;
- /// then marshals the notification onto the UI thread so bindings
- /// observe the change on the right thread.
- ///
- private readonly object _mutationGate = new();
-
///
/// 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();
}
- ///
- /// Scopes the PostIt client always requires from the OIDC provider,
- /// regardless of what the user has in their settings file.
- ///
- /// PostIt calls into the Blog API (and any other Yavsc API
- /// gated by an [Authorize("…Scope")] 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. blogs) are still their choice — we only force the
- /// structural ones that OIDC itself needs.
- ///
- 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"
-
- };
///
/// Merge user-configured scopes with the built-in ones. User scopes
@@ -247,16 +259,42 @@ public partial class Settings : ViewModelBase
return;
}
}
-
+ if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
+ && !string.IsNullOrWhiteSpace(envJson))
+ {
+ Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
+ FileInfo configByEnvFileInfo = new FileInfo(envJson);
+ if (!configByEnvFileInfo.Exists)
+ {
+ throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
+ }
+ string json = File.ReadAllText(configByEnvFileInfo.FullName);
+ ApplyJson(json, "POSTIT_SETTINGS_JSON");
+ SettingsFileFullName = configByEnvFileInfo.FullName;
+ Loaded = true;
+ return;
+ }
string configDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "PostIt"
-);
- Directory.CreateDirectory(configDir);
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "PostIt"
+ );
- 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))
+ {
+ SettingsFileFullName = envPath;
+ }
+ else
+ {
+ SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
+ }
- FileInfo configFileInfo = new FileInfo(configPath);
+ FileInfo configFileInfo = new FileInfo(SettingsFileFullName);
if (!configFileInfo.Exists)
{
@@ -285,6 +323,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 +375,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 +385,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 +431,7 @@ public partial class Settings : ViewModelBase
}
}
- private static string? TryReadLegacyApiUrl(string json)
+ private static string? TryReadApiUrl(string json)
{
try
{
@@ -424,6 +464,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;
}
///
@@ -446,11 +487,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 +513,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)
diff --git a/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
rename to src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs
diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml b/src/PostIt/PostIt/Views/ACL/AddCircleMemberDialog.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml
rename to src/PostIt/PostIt/Views/ACL/AddCircleMemberDialog.axaml
diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs b/src/PostIt/PostIt/Views/ACL/AddCircleMemberDialog.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
rename to src/PostIt/PostIt/Views/ACL/AddCircleMemberDialog.axaml.cs
diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/ACL/CirclesPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/CirclesPage.axaml
rename to src/PostIt/PostIt/Views/ACL/CirclesPage.axaml
diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml.cs b/src/PostIt/PostIt/Views/ACL/CirclesPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/CirclesPage.axaml.cs
rename to src/PostIt/PostIt/Views/ACL/CirclesPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/PostAclDialog.axaml b/src/PostIt/PostIt/Views/ACL/PostAclDialog.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/PostAclDialog.axaml
rename to src/PostIt/PostIt/Views/ACL/PostAclDialog.axaml
diff --git a/src/PostIt/PostIt/Views/PostAclDialog.axaml.cs b/src/PostIt/PostIt/Views/ACL/PostAclDialog.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/PostAclDialog.axaml.cs
rename to src/PostIt/PostIt/Views/ACL/PostAclDialog.axaml.cs
diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml b/src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/ActivitiesPage.axaml
rename to src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml
diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/ActivitiesPage.axaml.cs
rename to src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/BillingQueriesPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/BillingQueriesPage.axaml
rename to src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml
diff --git a/src/PostIt/PostIt/Views/BillingQueriesPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/BillingQueriesPage.axaml.cs
rename to src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml
rename to src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml
diff --git a/src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml.cs
rename to src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/CommandFormsPage.axaml b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml
similarity index 91%
rename from src/PostIt/PostIt/Views/CommandFormsPage.axaml
rename to src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml
index f44c098bd..2565092e5 100644
--- a/src/PostIt/PostIt/Views/CommandFormsPage.axaml
+++ b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml
@@ -34,13 +34,13 @@
diff --git a/src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs
rename to src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
new file mode 100644
index 000000000..7b25ab226
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs
new file mode 100644
index 000000000..1c58efa10
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs
@@ -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);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
new file mode 100644
index 000000000..d5055dd1f
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
new file mode 100644
index 000000000..0eac3b66e
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class EstimateListPage : ContentPage
+{
+ public EstimateListPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml
new file mode 100644
index 000000000..173fe903b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs
new file mode 100644
index 000000000..ce2e9ad40
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs
@@ -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);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
new file mode 100644
index 000000000..cb63ed6d3
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
new file mode 100644
index 000000000..09ccc79f4
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
@@ -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(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"
+ };
+ }
+}
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
similarity index 65%
rename from src/PostIt/PostIt/Views/HomePage.axaml
rename to src/PostIt/PostIt/Views/Layout/HomePage.axaml
index 7f6527e3d..3b62b891b 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -22,5 +22,17 @@
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
+
+
+
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml.cs b/src/PostIt/PostIt/Views/Layout/HomePage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/HomePage.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/HomePage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/MainView.axaml b/src/PostIt/PostIt/Views/Layout/MainView.axaml
similarity index 92%
rename from src/PostIt/PostIt/Views/MainView.axaml
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml
index 643c0f208..92f53eb54 100644
--- a/src/PostIt/PostIt/Views/MainView.axaml
+++ b/src/PostIt/PostIt/Views/Layout/MainView.axaml
@@ -6,11 +6,11 @@
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
- x:DataType="vm:MainViewModel">
+ x:DataType="vm:HomePageViewModel">
-
+
diff --git a/src/PostIt/PostIt/Views/MainView.axaml.cs b/src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
similarity index 87%
rename from src/PostIt/PostIt/Views/MainView.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
index 654b90e2d..22bf537c6 100644
--- a/src/PostIt/PostIt/Views/MainView.axaml.cs
+++ b/src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
@@ -13,7 +13,7 @@ public partial class MainView : UserControl
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
- if (DataContext is ViewModels.MainViewModel vm)
+ if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml b/src/PostIt/PostIt/Views/Layout/MainWindow.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/MainWindow.axaml
rename to src/PostIt/PostIt/Views/Layout/MainWindow.axaml
diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml.cs b/src/PostIt/PostIt/Views/Layout/MainWindow.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/MainWindow.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/MainWindow.axaml.cs
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/SessionStatusBanner.axaml
rename to src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml.cs b/src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/SessionStatusBanner.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml.cs
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/SettingsPage.axaml
rename to src/PostIt/PostIt/Views/Layout/SettingsPage.axaml
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml.cs b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/SettingsPage.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
deleted file mode 100644
index 871a2f541..000000000
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs
deleted file mode 100644
index 97e3f9227..000000000
--- a/src/PostIt/PostIt/Views/MainPage.axaml.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-
-namespace PostIt.Views;
-
-public partial class MainPage : ContentPage
-{
- public MainPage()
- {
- InitializeComponent();
- }
-
- protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
- {
- base.OnApplyTemplate(e);
- if (DataContext is ViewModels.MainViewModel vm)
- {
- if (!vm.IsLoaded)
- {
- vm.RefreshAsync().Wait();
- }
- }
- }
-}
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/SignaturePage.axaml
rename to src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml.cs b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/SignaturePage.axaml.cs
rename to src/PostIt/PostIt/Views/Signature/SignaturePage.axaml.cs
diff --git a/src/PostIt/PostIt/postit-settings sample.json b/src/PostIt/PostIt/postit-settings sample.json
deleted file mode 100644
index e23839b1f..000000000
--- a/src/PostIt/PostIt/postit-settings sample.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "Authentication": {
- "ClientId": "postit",
- "Authority": "https://yavsc.pschneider.fr"
- },
- "RedirectUri": "postit://callback",
- "DarkMode": false,
- "ApiUrl": "https://api.pschneider.fr/api/v1/",
- "Scopes": [
- "openid",
- "profile",
- "offline_access",
- "blogs"
- ]
-}
diff --git a/src/PostIt/PostIt/postit-settings-sample.json b/src/PostIt/PostIt/postit-settings-sample.json
new file mode 100644
index 000000000..fbf1f68a0
--- /dev/null
+++ b/src/PostIt/PostIt/postit-settings-sample.json
@@ -0,0 +1,17 @@
+{
+ "Authentication": {
+ "ClientId": "postit",
+ "Authority": "https://yavsc.pschneider.fr",
+ "Scopes": [
+ "openid",
+ "profile",
+ "offline_access",
+ "blogs",
+ "api"
+ ],
+ "RedirectUri": "postit://callback"
+ },
+ "DarkMode": false,
+ "ApiUrl": "https://api.pschneider.fr/api/v1/"
+
+}
diff --git a/src/PostIt/PostIt/postit-settings.json b/src/PostIt/PostIt/postit-settings.json
index 080fed178..f62a9ac1c 100644
--- a/src/PostIt/PostIt/postit-settings.json
+++ b/src/PostIt/PostIt/postit-settings.json
@@ -1,15 +1,21 @@
{
- "Authentication": {
- "ClientId": "postit",
- "Authority": "https://yavsc.pschneider.fr/"
- },
- "RedirectUri": "postit://callback",
- "DarkMode": true,
- "ApiUrl": "https://api.pschneider.fr/api/v1/",
+ "Authentication": {
+ "Authority": "https://localhost:5001",
+ "ClientId": "postit",
"Scopes": [
- "openid",
- "profile",
- "offline_access",
- "blogs"
- ]
-}
+ "blogs",
+ "api"
+ ],
+ "RedirectUri": "postit://callback"
+ },
+ "DarkMode": true,
+ "BlogsApiUrl": "https://localhost:5003/api/v1/",
+ "ApiUrl": "https://localhost:5005/api/v1/",
+ "SearchText": "",
+ "ProviderOngoingRequestsSortOption": "",
+ "Loaded": true,
+ "IsDirty": true,
+ "CanNavigateNext": false,
+ "CanNavigatePrevious": true,
+ "SaveCommand": {}
+}
\ No newline at end of file
diff --git a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
index 14bc86435..7665df8ab 100644
--- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
+++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
@@ -7,8 +7,6 @@ namespace Yavsc.Server.Helpers
{
public static class AbstractFileSystemHelpers
{
- public static string UserBillsDirName { set; get; }
- public static string UserFilesDirName { set; get; }
///
/// Is Valid this Path?
@@ -64,17 +62,6 @@ namespace Yavsc.Server.Helpers
return sb.ToString();
}
- public static UserDirectoryInfo GetUserFiles(string userId, string subdir)
- {
- UserDirectoryInfo di = new UserDirectoryInfo(UserFilesDirName, userId, subdir);
- return di;
- }
- public static bool IsRegularFile(string userName, string subdir)
- {
- FileInfo fi = new FileInfo( Path.Combine(Path.Combine(UserFilesDirName, userName), subdir));
- return fi.Exists;
- }
-
// Server side only supports POSIX file systems
public const char RemoteDirectorySeparator = '/';
diff --git a/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs b/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
new file mode 100644
index 000000000..64bcb405b
--- /dev/null
+++ b/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
@@ -0,0 +1,67 @@
+namespace Yavsc.Abstract.Files;
+
+///
+/// Helpers pour dériver les URL publiques des fichiers statiques à partir
+/// d'une URL d'autorité OIDC ou d'un autre point d'entrée racine.
+///
+public static class FileServerUrlHelpers
+{
+ ///
+ /// Dérive la racine publique des fichiers utilisateur en alignant
+ /// le chemin sur .
+ ///
+ ///
+ /// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
+ ///
+ /// Une URL absolue pointant vers la racine des fichiers utilisateur.
+ public static Uri GetUserFilesBaseUri(Uri authorityBaseUrl)
+ {
+ ArgumentNullException.ThrowIfNull(authorityBaseUrl);
+
+ if (!authorityBaseUrl.IsAbsoluteUri)
+ {
+ throw new ArgumentException(
+ "The authority base URL must be absolute.",
+ nameof(authorityBaseUrl));
+ }
+
+ var baseString = authorityBaseUrl.GetLeftPart(UriPartial.Authority);
+ return new Uri(new Uri(baseString, UriKind.Absolute), EnsureTrailingSlash(Yavsc.Constants.UserFilesPath));
+ }
+
+ ///
+ /// Dérive la racine publique des fichiers utilisateur en alignant
+ /// le chemin sur .
+ ///
+ ///
+ /// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
+ ///
+ /// Une URL absolue pointant vers la racine des fichiers utilisateur.
+ public static Uri GetUserFilesBaseUri(string authorityBaseUrl)
+ => GetUserFilesBaseUri(new Uri(authorityBaseUrl, UriKind.Absolute));
+
+ ///
+ /// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
+ /// et d'un chemin relatif sous la racine des fichiers.
+ ///
+ public static Uri GetUserFilesUri(Uri authorityBaseUrl, string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+
+ var baseUri = GetUserFilesBaseUri(authorityBaseUrl);
+ return new Uri(baseUri, NormalizeRelativePath(relativePath));
+ }
+
+ ///
+ /// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
+ /// et d'un chemin relatif sous la racine des fichiers.
+ ///
+ public static Uri GetUserFilesUri(string authorityBaseUrl, string relativePath)
+ => GetUserFilesUri(new Uri(authorityBaseUrl, UriKind.Absolute), relativePath);
+
+ private static string EnsureTrailingSlash(string path)
+ => path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/";
+
+ private static string NormalizeRelativePath(string relativePath)
+ => relativePath.TrimStart('/');
+}
diff --git a/src/Yavsc.Abstract/IT/CodeFromChars.cs b/src/Yavsc.Abstract/IT/CodeFromChars.cs
index ff441568e..e0e31ce28 100644
--- a/src/Yavsc.Abstract/IT/CodeFromChars.cs
+++ b/src/Yavsc.Abstract/IT/CodeFromChars.cs
@@ -44,7 +44,6 @@ namespace Yavsc.Abstract.IT
public bool Validate()
{
// this is a n*n task
-
throw new NotImplementedException();
}
diff --git a/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs b/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs
new file mode 100644
index 000000000..af3af22bc
--- /dev/null
+++ b/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs
@@ -0,0 +1,12 @@
+
+namespace Yavsc.Models.Access
+{
+ using Yavsc.Abstract.Identity.Security;
+
+ public class FileAccessControlRulePayload : CircleAuthorization
+ {
+ public virtual string Path { get; set; }
+
+
+ }
+}
diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
index 78ffe531e..4df6c53c2 100644
--- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj
+++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
@@ -11,7 +11,7 @@
latest
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/BillingApiClient.cs b/src/Yavsc.Api.Client/BillingApiClient.cs
index c3a0be103..a9e9deda6 100644
--- a/src/Yavsc.Api.Client/BillingApiClient.cs
+++ b/src/Yavsc.Api.Client/BillingApiClient.cs
@@ -77,6 +77,14 @@ public sealed class BillingApiClient
return items;
}
+ public Task> GetProviderOngoingQueriesAsync(CancellationToken ct = default)
+ {
+ return _api.CallAsync>(
+ HttpMethod.Get,
+ Absolute("bill/provider/ongoing"),
+ ct: ct);
+ }
+
public async Task GetQueryAsync(string billingCode, long queryId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
@@ -156,6 +164,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@@ -183,6 +192,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@@ -212,6 +222,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@@ -305,6 +316,11 @@ public sealed class BillingApiClient
["Address"] = location.Address,
};
+ if (location.Id.HasValue && location.Id.Value > 0)
+ {
+ payload["Id"] = location.Id.Value;
+ }
+
if (location.Latitude.HasValue)
{
payload["Latitude"] = location.Latitude.Value;
@@ -320,6 +336,7 @@ public sealed class BillingApiClient
private sealed class BillingLocationResponse
{
+ public long Id { get; set; }
public string? Address { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs
index 8284c5ed9..48704dcb9 100644
--- a/src/Yavsc.Api.Client/BlogApiClient.cs
+++ b/src/Yavsc.Api.Client/BlogApiClient.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
+using System.Net.Http.Headers;
+using System.Text.Json;
using Yavsc.Blogspot;
namespace Yavsc.Api.Client;
@@ -62,11 +59,18 @@ public sealed class BlogApiClient
public Task GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
- public Task CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
- => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
+ public Task CreatePostAsync(
+ BlogPostDto post,
+ IReadOnlyCollection? files = null,
+ CancellationToken ct = default)
+ => SendPostAsync(HttpMethod.Post, _pathPrefix, post, files, ct);
- public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
- => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
+ public Task UpdatePostAsync(
+ long id,
+ BlogPostDto post,
+ IReadOnlyCollection? files = null,
+ CancellationToken ct = default)
+ => SendPostAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", post, files, ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
@@ -81,4 +85,50 @@ public sealed class BlogApiClient
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
+
+ private async Task SendPostAsync(
+ HttpMethod method,
+ string path,
+ BlogPostDto post,
+ IReadOnlyCollection? files,
+ CancellationToken ct)
+ {
+ if (files is null || files.Count == 0)
+ return await _api.CallAsync(method, path, body: post, ct: ct);
+
+ return await _api.CallAsync(method, path, () => CreateMultipartContent(post, files), ct: ct);
+ }
+
+ /// Build the multipart/form-data body PostIt
+ /// sends when a blog post is saved with one or more
+ /// attachments: a blog field holding the
+ /// serialised camelCase with
+ /// WhenWritingNull, plus one file part per entry
+ /// in (each carrying its
+ /// , falling back to
+ /// application/octet-stream when unset). Exposed as
+ /// public so the blogs backend tests can assert on the
+ /// wire shape directly and round-trip it through the live
+ /// BlogsWebServerFixture host.
+ public static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files)
+ {
+ var content = new MultipartFormDataContent();
+ var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ });
+
+ content.Add(new StringContent(blogJson), "blog");
+
+ foreach (var file in files)
+ {
+ var fileContent = new ByteArrayContent(file.Content);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue(
+ string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
+ content.Add(fileContent, "file", file.FileName);
+ }
+
+ return content;
+ }
}
diff --git a/src/Yavsc.Api.Client/BlogUploadFile.cs b/src/Yavsc.Api.Client/BlogUploadFile.cs
new file mode 100644
index 000000000..4b3a7b26d
--- /dev/null
+++ b/src/Yavsc.Api.Client/BlogUploadFile.cs
@@ -0,0 +1,6 @@
+namespace Yavsc.Api.Client;
+
+///
+/// Buffered file payload for multipart blog uploads.
+///
+public sealed record BlogUploadFile(string FileName, byte[] Content, string? ContentType = null);
diff --git a/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
index 6d658ce49..eb33ba816 100644
--- a/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
+++ b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Text.Json.Serialization;
using Yavsc;
namespace Yavsc.Api.Client;
@@ -29,7 +30,14 @@ public sealed class BillingQueryDetailsDto
public sealed class BillingLocationDto
{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public long? Id { get; set; }
+
public string Address { get; set; } = string.Empty;
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Latitude { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Longitude { get; set; }
}
diff --git a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
new file mode 100644
index 000000000..43c09d94a
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace Yavsc.Api.Client;
+
+///
+/// A single billable line of an estimate, mirroring the JSON shape of
+/// the server-side Yavsc.Models.Billing.CommandLine entity.
+///
+public sealed class EstimateLineDto
+{
+ public long Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public int Count { get; set; } = 1;
+ public decimal UnitaryCost { get; set; }
+ public long EstimateId { get; set; }
+ public string Currency { get; set; } = "EUR";
+}
+
+///
+/// Estimate payload exchanged with the api/v1/estimate routes
+/// (EstimateApiController). and
+/// are always initialised: the server-side
+/// entity reads them from non-nullable string properties and a null
+/// list would break its serialisation.
+///
+public sealed class EstimateDto
+{
+ public long Id { get; set; }
+ public long? CommandId { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public List Bill { get; set; } = new();
+ public List AttachedGraphics { get; set; } = new();
+ public List AttachedFiles { get; set; } = new();
+ public string? OwnerId { get; set; }
+ public string ClientId { get; set; } = string.Empty;
+ public string CommandType { get; set; } = string.Empty;
+ public DateTime ProviderValidationDate { get; set; }
+ public DateTime ClientValidationDate { get; set; }
+}
+
+///
+/// Response of a successful estimate creation
+/// (Ok(new { estimate.Id, estimate.Bill })).
+///
+public sealed class EstimateCreatedDto
+{
+ public long Id { get; set; }
+ public List Bill { get; set; } = new();
+}
diff --git a/src/Yavsc.Api.Client/EstimateApiClient.cs b/src/Yavsc.Api.Client/EstimateApiClient.cs
new file mode 100644
index 000000000..4a80bbe62
--- /dev/null
+++ b/src/Yavsc.Api.Client/EstimateApiClient.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for the estimate routes (api/v1/estimate,
+/// served by EstimateApiController). Follows the same
+/// DTO↔path mapper shape as : all
+/// transport concerns (base URL, JSON, Bearer auth, silent refresh
+/// on 401) are delegated to .
+///
+public sealed class EstimateApiClient
+{
+ private const string PathPrefix = "estimate";
+
+ private readonly IYavscApiClient _api;
+ private readonly Func _businessBaseAddress;
+
+ public EstimateApiClient(IYavscApiClient api, string businessBaseAddress)
+ : this(api, () => businessBaseAddress)
+ {
+ }
+
+ public EstimateApiClient(IYavscApiClient api, Func businessBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
+
+ // Validate initial value early to fail fast on invalid setup.
+ _ = ResolveBusinessBaseAddress();
+ }
+
+ ///
+ /// Lists the estimates of the given owner; when
+ /// is null, the server falls back to the current user.
+ ///
+ public Task> GetEstimatesAsync(string? ownerId = null, CancellationToken ct = default)
+ {
+ var path = string.IsNullOrWhiteSpace(ownerId)
+ ? PathPrefix
+ : $"{PathPrefix}?ownerId={Uri.EscapeDataString(ownerId)}";
+
+ return _api.CallAsync>(HttpMethod.Get, Absolute(path), ct: ct);
+ }
+
+ public Task GetEstimateAsync(long id, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+
+ return _api.CallAsync(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
+ }
+
+ ///
+ /// Lists the ongoing estimates (not yet validated by the client) where
+ /// the current user is the client who initiated the request
+ /// (GET estimate/asclient).
+ ///
+ public Task> GetOngoingEstimatesAsClientAsync(CancellationToken ct = default)
+ => _api.CallAsync>(HttpMethod.Get, Absolute($"{PathPrefix}/asclient"), ct: ct);
+
+ ///
+ /// Lists the ongoing estimates (not yet validated by the client)
+ /// established by the current user as the provider
+ /// (GET estimate/asprovider).
+ ///
+ public Task> GetOngoingEstimatesAsProviderAsync(CancellationToken ct = default)
+ => _api.CallAsync>(HttpMethod.Get, Absolute($"{PathPrefix}/asprovider"), ct: ct);
+
+ ///
+ /// Creates an estimate. When is set,
+ /// the server also stamps the linked command as validated.
+ ///
+ public async Task CreateAsync(EstimateDto estimate, CancellationToken ct = default)
+ {
+ if (estimate is null)
+ throw new ArgumentNullException(nameof(estimate));
+
+ var created = await _api.CallAsync(
+ HttpMethod.Post,
+ Absolute(PathPrefix),
+ body: estimate,
+ ct: ct).ConfigureAwait(false);
+
+ return created ?? new EstimateCreatedDto();
+ }
+
+ public Task UpdateAsync(long id, EstimateDto estimate, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+ if (estimate is null)
+ throw new ArgumentNullException(nameof(estimate));
+
+ estimate.Id = id;
+
+ return _api.CallAsync(
+ HttpMethod.Put,
+ Absolute($"{PathPrefix}/{id}"),
+ body: estimate,
+ ct: ct);
+ }
+
+ public Task DeleteAsync(long id, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+
+ return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
+ }
+
+ private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
+
+ private Uri ResolveBusinessBaseAddress()
+ {
+ var raw = _businessBaseAddress();
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException("Business base address is required.");
+
+ return new Uri(raw, UriKind.Absolute);
+ }
+}
diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs
index 209ec07d7..1d470b682 100644
--- a/src/Yavsc.Api.Client/IYavscApiClient.cs
+++ b/src/Yavsc.Api.Client/IYavscApiClient.cs
@@ -53,10 +53,24 @@ public interface IYavscApiClient : IAsyncDisposable
object? body = null,
CancellationToken ct = default);
+ /// Call a multipart endpoint with a typed return value.
+ Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func contentFactory,
+ CancellationToken ct = default);
+
/// Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
+
+ /// Call a multipart endpoint that returns no useful body.
+ Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func contentFactory,
+ CancellationToken ct = default);
}
diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
index 6ceaab113..f0cdb55c9 100644
--- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
+++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
@@ -17,8 +17,8 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
- 1.1.0-beta.1
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
diff --git a/src/Yavsc.Api.Test/BillingControllerTests.cs b/src/Yavsc.Api.Test/BillingControllerTests.cs
new file mode 100644
index 000000000..38b9a9919
--- /dev/null
+++ b/src/Yavsc.Api.Test/BillingControllerTests.cs
@@ -0,0 +1,236 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Helpers;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class BillingControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public BillingControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+
+ return http;
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_returns_current_provider_requests()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedActivityGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var location = db.Locations.Single();
+
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow.AddMinutes(-10),
+ DateModified = DateTime.UtcNow.AddMinutes(-8),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Rendez-vous fournisseur",
+ Status = QueryStatus.InProgress,
+ Description = "Commande fournisseur en cours",
+ });
+
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "alice",
+ PerformerId = "bob",
+ Consent = true,
+ UserCreated = "bob",
+ UserModified = "bob",
+ DateCreated = DateTime.UtcNow.AddMinutes(-20),
+ DateModified = DateTime.UtcNow.AddMinutes(-20),
+ EventDate = DateTime.UtcNow.AddDays(2),
+ Location = location,
+ Reason = "Commande d'un autre prestataire",
+ Status = QueryStatus.Accepted,
+ Description = "Autre prestataire",
+ });
+
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.NotEmpty(payload!);
+ Assert.All(payload!, item => Assert.Equal("alice", item.PerformerId));
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv);
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_ignores_rows_with_invalid_discriminator()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedActivityGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ db.Database.ExecuteSqlInterpolated($@"
+INSERT INTO ""NominativeServiceCommands""
+(""ActivityCode"", ""ClientId"", ""Consent"", ""DateCreated"", ""DateModified"", ""Description"", ""Discriminator"", ""PerformerId"", ""Status"", ""UserCreated"", ""UserModified"")
+VALUES
+({"dev"}, {"bob"}, {true}, {DateTime.UtcNow.AddMinutes(-5)}, {DateTime.UtcNow.AddMinutes(-4)}, {"Legacy malformed row"}, {""}, {"alice"}, {(int)QueryStatus.Accepted}, {"alice"}, {"alice"});
+");
+
+ var location = db.Locations.Single();
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow.AddMinutes(-3),
+ DateModified = DateTime.UtcNow.AddMinutes(-2),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Commande valide",
+ Status = QueryStatus.InProgress,
+ Description = "Commande fournisseur valide",
+ });
+
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.NotEmpty(payload!);
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv && item.PerformerId == "alice");
+ Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode));
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedHaircutGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "alice",
+ DoesCode = "brush",
+ Weight = 50,
+ });
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "alice",
+ DoesCode = "mbrush",
+ Weight = 50,
+ });
+ db.CommandForm.Add(new CommandForm
+ {
+ ActivityCode = "brush",
+ ActionName = BillingCodes.Brush,
+ Title = "Brush",
+ });
+ db.CommandForm.Add(new CommandForm
+ {
+ ActivityCode = "mbrush",
+ ActionName = BillingCodes.MBrush,
+ Title = "MBrush",
+ });
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice");
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice");
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedHaircutGraph();
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush);
+ Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush);
+ }
+
+ private sealed class ProviderOngoingCommandDto
+ {
+ public long Id { get; set; }
+ public string BillingCode { get; set; } = string.Empty;
+ public string ActivityCode { get; set; } = string.Empty;
+ public string PerformerId { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public QueryStatus Status { get; set; }
+ public string Description { get; set; } = string.Empty;
+ }
+}
diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
new file mode 100644
index 000000000..cb945f51a
--- /dev/null
+++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
@@ -0,0 +1,315 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class EstimateApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public EstimateApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+
+ return http;
+ }
+
+ ///
+ /// Seed a provider (alice) and a client (bob) with a pending
+ /// from bob to alice, and return the
+ /// command id.
+ ///
+ private long SeedPendingCommand()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var location = db.Locations.Single();
+
+ var query = new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "bob",
+ UserModified = "bob",
+ DateCreated = DateTime.UtcNow.AddMinutes(-10),
+ DateModified = DateTime.UtcNow.AddMinutes(-10),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Demande de devis",
+ Status = QueryStatus.InProgress,
+ Description = "Demande en attente de devis",
+ };
+ db.RdvQueries.Add(query);
+ db.SaveChanges();
+
+ return query.Id;
+ }
+
+ private static object NewEstimatePayload(long? commandId, string clientId, string? ownerId = null)
+ => new
+ {
+ CommandId = commandId,
+ ClientId = clientId,
+ OwnerId = ownerId,
+ CommandType = BillingCodes.Rdv,
+ Title = "Devis prestation",
+ Description = "Devis détaillé",
+ AttachedFiles = Array.Empty(),
+ AttachedGraphics = Array.Empty(),
+ Bill = new[]
+ {
+ new { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
+ new { Name = "Remise", Description = "Remise fidélité", Count = 1, UnitaryCost = -20m, Currency = "EUR" },
+ },
+ };
+
+ [Fact]
+ public async Task PostEstimate_creates_the_estimate_and_validates_the_linked_command()
+ {
+ var commandId = SeedPendingCommand();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var estimateId = doc.RootElement.GetProperty("id").GetInt64();
+ Assert.True(estimateId > 0);
+ Assert.Equal(2, doc.RootElement.GetProperty("bill").GetArrayLength());
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var estimate = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId);
+ Assert.Equal("alice", estimate.OwnerId);
+ Assert.Equal("bob", estimate.ClientId);
+ Assert.Equal(commandId, estimate.CommandId);
+ Assert.Equal(BillingCodes.Rdv, estimate.CommandType);
+ Assert.Equal(2, estimate.Bill.Count);
+ Assert.Contains(estimate.Bill, line => line.UnitaryCost == -20m);
+
+ // PostEstimate stamps the linked command as validated.
+ var query = db.RdvQueries.Single(q => q.Id == commandId);
+ Assert.NotNull(query.ValidationDate);
+ }
+
+ [Fact]
+ public async Task PostEstimate_without_command_creates_a_standalone_estimate()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var estimateId = doc.RootElement.GetProperty("id").GetInt64();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var estimate = db.Estimates.Single(e => e.Id == estimateId);
+ Assert.Null(estimate.CommandId);
+ Assert.Equal("alice", estimate.OwnerId);
+ }
+
+ [Fact]
+ public async Task PostEstimate_for_another_owner_is_rejected()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob", ownerId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Empty(db.Estimates);
+ }
+
+ [Fact]
+ public async Task PostEstimate_with_an_unknown_command_id_is_rejected()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: 999999, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Empty(db.Estimates);
+ }
+
+ [Fact]
+ public async Task PostEstimate_without_token_is_unauthorized()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ ///
+ /// Seeds an estimate owned by alice (the provider) for the given
+ /// client, optionally already validated by the client.
+ ///
+ private long SeedEstimate(string clientId, bool clientValidated)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var estimate = new Estimate
+ {
+ ClientId = clientId,
+ OwnerId = "alice",
+ CommandType = BillingCodes.Rdv,
+ Title = "Devis prestation",
+ Description = "Devis de test",
+ AttachedFiles = new List(),
+ AttachedGraphics = new List(),
+ Bill = new List
+ {
+ new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
+ },
+ ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
+ };
+ db.Estimates.Add(estimate);
+ db.SaveChanges();
+ return estimate.Id;
+ }
+
+ [Fact]
+ public async Task GetOngoingEstimatesAsClient_returns_only_the_current_users_ongoing_estimates()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
+ SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
+ SeedEstimate(clientId: "alice", clientValidated: false); // another client's: excluded
+
+ using var http = NewClient("bob");
+
+ var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var items = doc.RootElement.EnumerateArray().ToArray();
+ var item = Assert.Single(items);
+ Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
+ Assert.Equal("bob", item.GetProperty("clientId").GetString());
+ Assert.Equal("alice", item.GetProperty("ownerId").GetString());
+ }
+
+ [Fact]
+ public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
+ SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
+
+ using var http = NewClient("alice");
+
+ var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var items = doc.RootElement.EnumerateArray().ToArray();
+ var item = Assert.Single(items);
+ Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
+ Assert.Equal("alice", item.GetProperty("ownerId").GetString());
+ Assert.Single(item.GetProperty("bill").EnumerateArray());
+ }
+
+ [Fact]
+ public async Task GetOngoingEstimatesAsClient_without_token_is_unauthorized()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
+
+ var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetOngoingEstimatesAsProvider_without_token_is_unauthorized()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
+
+ var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}
diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
index e2a8b3376..51e939ceb 100644
--- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
+++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
@@ -1,45 +1,72 @@
-using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
+using Npgsql;
using Yavsc.Controllers;
+using Yavsc.Interfaces.Workflow;
using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
+using Yavsc.Models.Messaging;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
+using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test.Fixtures;
public sealed class ApiWebServerFixture : WebHostFixture
{
+ private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
+ private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
+ private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
+ private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
+
protected override int HttpsPort => 5104;
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
+ private static readonly object _npgsqlLock = new();
+ private static string? _sharedNpgsqlConnectionString;
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
- SqliteConnection sharedConnection;
- lock (_sqliteLock)
+ if (UseNpgsqlProvider())
{
- if (_sharedSqliteConnection is null)
- {
- _sharedSqliteConnection = new SqliteConnection(
- "Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
- _sharedSqliteConnection.Open();
- }
- sharedConnection = _sharedSqliteConnection;
+ var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
+ builder.Services.AddDbContext(opt =>
+ opt.UseNpgsql(npgsqlConnectionString,
+ x => x.MigrationsAssembly("Yavsc.Org")));
}
+ else
+ {
+ SqliteConnection sharedConnection;
+ lock (_sqliteLock)
+ {
+ if (_sharedSqliteConnection is null)
+ {
+ _sharedSqliteConnection = new SqliteConnection(
+ "Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
+ _sharedSqliteConnection.Open();
+ }
+ sharedConnection = _sharedSqliteConnection;
+ }
- builder.Services.AddDbContext(opt =>
- opt.UseSqlite(sharedConnection));
+ builder.Services.AddDbContext(opt =>
+ opt.UseSqlite(sharedConnection));
+ }
builder.Services.AddControllers()
.AddApplicationPart(typeof(ActivityApiController).Assembly);
+ builder.Services.AddLocalization();
+ builder.Services.Configure(_ => { });
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer")
@@ -74,7 +101,18 @@ public sealed class ApiWebServerFixture : WebHostFixture
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
- db.Database.EnsureCreated();
+ if (UseNpgsqlProvider())
+ {
+ // Apply the EF Core migrations (Yavsc.Org assembly) so the
+ // test database schema matches the production provider.
+ // EnsureCreated must not be used here: it would create the
+ // schema without the migrations history and break Migrate().
+ db.Database.Migrate();
+ }
+ else
+ {
+ db.Database.EnsureCreated();
+ }
}
await Task.CompletedTask;
@@ -83,12 +121,97 @@ public sealed class ApiWebServerFixture : WebHostFixture
public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
+ public static bool UseNpgsqlProvider()
+ => string.Equals(
+ Environment.GetEnvironmentVariable(DbProviderEnvVar),
+ "npgsql",
+ StringComparison.OrdinalIgnoreCase);
+
+ private static string EnsureNpgsqlDatabaseCreated()
+ {
+ lock (_npgsqlLock)
+ {
+ if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
+ {
+ return _sharedNpgsqlConnectionString;
+ }
+
+ var adminConnectionString = BuildAdminConnectionString();
+ var databaseName = DedicatedNpgsqlDatabaseName;
+
+ using (var adminConnection = new NpgsqlConnection(adminConnectionString))
+ {
+ adminConnection.Open();
+ using var existsCommand = adminConnection.CreateCommand();
+ existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
+ existsCommand.Parameters.AddWithValue("databaseName", databaseName);
+
+ if (existsCommand.ExecuteScalar() is null)
+ {
+ using var createCommand = adminConnection.CreateCommand();
+ createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
+ createCommand.ExecuteNonQuery();
+ }
+ }
+
+ var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
+ {
+ Database = databaseName,
+ Pooling = false,
+ IncludeErrorDetail = true
+ };
+
+ _sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
+ return _sharedNpgsqlConnectionString;
+ }
+ }
+
+ private static string BuildAdminConnectionString()
+ {
+ var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
+ var source = string.IsNullOrWhiteSpace(configured)
+ ? DefaultDevelopmentConnectionString
+ : configured;
+
+ var builder = new NpgsqlConnectionStringBuilder(source)
+ {
+ Pooling = false,
+ IncludeErrorDetail = true
+ };
+
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ builder.Database = "postgres";
+ }
+ else if (string.IsNullOrWhiteSpace(builder.Database))
+ {
+ builder.Database = "postgres";
+ }
+
+ return builder.ToString();
+ }
+
+ private sealed class NoopMessageSender : IYavscMessageSender
+ {
+ public Task NotifyBookQueryAsync(IEnumerable connectionIds, RdvQueryEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyEstimateAsync(IEnumerable connectionIds, EstimationEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyHairCutQueryAsync(IEnumerable connectionIds, HairCutQueryEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyAsync(IEnumerable connectionIds, IEvent yaev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+ }
+
public void ResetAndSeedActivityGraph()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
- db.Database.EnsureDeleted();
+ ResetDatabase(db);
db.Database.EnsureCreated();
var user = new ApplicationUser
@@ -189,6 +312,34 @@ public sealed class ApiWebServerFixture : WebHostFixture
db.SaveChanges();
}
+ private static void ResetDatabase(ApplicationDbContext db)
+ {
+ if (UseNpgsqlProvider())
+ {
+ // Purge only the tables of the test graph, children before
+ // parents, so no DELETE violates a foreign key. Estimate and
+ // CommandLine reference NominativeServiceCommand (CommandId) and
+ // must be deleted before the Rdv/HairCut/HairMultiCut queries.
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+
+ db.SaveChanges();
+ return;
+ }
+
+ db.Database.EnsureDeleted();
+ }
+
public void ResetAndSeedRdvQueryGraph()
{
ResetAndSeedActivityGraph();
@@ -335,21 +486,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
public override void Dispose()
{
- try
- {
- base.Dispose();
- }
- finally
- {
- lock (_sqliteLock)
- {
- if (_sharedSqliteConnection is not null)
- {
- _sharedSqliteConnection.Close();
- _sharedSqliteConnection.Dispose();
- _sharedSqliteConnection = null;
- }
- }
- }
+ // Keep the shared in-memory SQLite connection alive for the
+ // whole test process. Closing it from one fixture instance can
+ // drop the schema while other tests are still running.
+ base.Dispose();
}
}
diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
index 668814239..c3e4aad2b 100644
--- a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
+++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
@@ -143,4 +143,87 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotNull(created!.Location);
+ Assert.True(created.Location.Id > 0);
+ Assert.NotEqual(999999L, created.Location.Id);
+ Assert.Equal("alice", created.ClientId);
+ }
+
+ [Fact]
+ public async Task PostQuery_without_location_returns_bad_request()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Reason = "Rendez-vous sans location",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostQuery_with_unknown_location_id_and_missing_address_returns_bad_request()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = new
+ {
+ Id = 777777L,
+ Address = "",
+ Latitude = 0.0,
+ Longitude = 0.0,
+ },
+ Reason = "Rendez-vous location invalide",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
+ }
}
diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
index 760f5ce84..ebe96dfa8 100644
--- a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
+++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
@@ -6,8 +6,11 @@
false
Yavsc.Api.Test
true
+ 1.1.0.0
+ 1.1.0.0
+ 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
+ 1.1.0-feat-sign-estimate.1
-
@@ -18,7 +21,6 @@
-
@@ -26,8 +28,7 @@
-
-
+
\ No newline at end of file
diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index 197e06b11..ba099e7c7 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using System.Security.Claims;
+using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.ViewModels;
using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Workflow;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
@@ -32,6 +35,7 @@ namespace Yavsc.ApiControllers
private readonly ILogger logger;
private readonly IBillingService billingService;
+ private readonly SiteSettings siteSettings;
public BillingController(
IAuthorizationService authorizationService,
@@ -40,7 +44,8 @@ namespace Yavsc.ApiControllers
ApplicationDbContext context,
IOptions googleSettings,
IYavscMessageSender GCMSender,
- IBillingService billingService
+ IBillingService billingService,
+ IOptions siteSettings
)
{
_googleSettings=googleSettings.Value;
@@ -50,6 +55,7 @@ namespace Yavsc.ApiControllers
this._localizer = SR;
_GCMSender=GCMSender;
this.billingService=billingService;
+ this.siteSettings = siteSettings.Value;
}
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
@@ -62,7 +68,7 @@ namespace Yavsc.ApiControllers
return new ChallengeResult();
}
- var fi = bill.GetBillInfo(billingService);
+ var fi = bill.GetBillInfo(billingService, siteSettings);
if (!fi.Exists) return Ok(new { Error = "Not generated" });
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
@@ -100,6 +106,114 @@ namespace Yavsc.ApiControllers
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}
+ ///
+ /// Lists ongoing service commands for the authenticated performer.
+ /// This endpoint is tailored for the PostIt provider homepage flow
+ /// ("Mes demandes en cours").
+ ///
+ [HttpGet("provider/ongoing")]
+ [Produces("application/json")]
+ public IActionResult GetProviderOngoingCommands()
+ {
+ var uid = User.GetUserId();
+ if (string.IsNullOrWhiteSpace(uid))
+ {
+ return Unauthorized();
+ }
+
+ if (billingService.BillingMap.Count == 0)
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ }
+
+ var allowedActivityCodes = dbContext.UserActivities
+ .AsNoTracking()
+ .Where(a => a.UserId == uid)
+ .Select(a => a.DoesCode)
+ .Distinct()
+ .ToList();
+
+ if (allowedActivityCodes.Count == 0)
+ {
+ return Ok(Array.Empty