diff --git a/.vscode/tasks.json b/.vscode/tasks.json index fd46437a..a30c9c06 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "fileLocation": ["relative", "${workspaceFolder}"], "source": "dotnet", "pattern": { - "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$", + "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", "file": 1, "line": 2, "column": 3, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd408c5c..559da62a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,19 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. +## Onboarding assiste par agents IA + +Pour accelerer la prise en main du depot avec Copilot/Plan/Explore : + +- Parcours pas-a-pas : [doc/onboarding-agents.md](./doc/onboarding-agents.md) +- Playbook d'usage des agents : [doc/agent-playbook.md](./doc/agent-playbook.md) +- Matrice intentions -> agent -> preuves : [doc/agent-intent-matrix.md](./doc/agent-intent-matrix.md) + +Regle minimale en contribution assistee par agent : +- expliciter l'impact architecture, +- justifier le niveau de tests execute, +- documenter les risques residuels. + ## Le CHANGELOG.md Le `CHANGELOG.md` est un document de changement de version diff --git a/README.md b/README.md index b132f3f2..549348f4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md) pour le sommaire complet. La racine de l'architecture est [Architecture.md](./doc/Architecture.md). +Pour une prise en main guidee avec agents IA: +- parcours onboarding: [doc/onboarding-agents.md](./doc/onboarding-agents.md) +- playbook d'usage: [doc/agent-playbook.md](./doc/agent-playbook.md) + # Construction et déploiement diff --git a/doc/README.md b/doc/README.md index 912a2e56..fb9bea94 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,6 +18,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé | +| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests | +| [agent-playbook.md](agent-playbook.md) | Playbook d'usage de Copilot, Plan, Explore avec scénarios et anti-patterns | +| [agent-intent-matrix.md](agent-intent-matrix.md) | Matrice intentions développeur -> agent -> preuves attendues | ## Roadmap & design exploration diff --git a/doc/agent-intent-matrix.md b/doc/agent-intent-matrix.md new file mode 100644 index 00000000..975cd7b7 --- /dev/null +++ b/doc/agent-intent-matrix.md @@ -0,0 +1,20 @@ +# Matrice intentions -> agent -> preuves + +Cette matrice aide a choisir rapidement l'agent adapte et a exiger +une sortie verifiable. + +| Intention developpeur | Agent principal | Entrees minimales | Sortie minimale attendue | Verification | +|---|---|---|---|---| +| Comprendre un BC avant changement | Explore | BC cible, profondeur, contrainte de perimetre | Composants, points d'entree, tests relies, risques | Lire les fichiers cites + confirmer tests proposes | +| Decomposer une tache transverse | Plan | Objectif, contraintes, definition of done | Etapes ordonnees, dependances, criteres de verif | Verifier que chaque etape a une preuve observable | +| Implementer une modif locale | Copilot | Fichier cible, comportement attendu, conventions | Patch minimal, justification courte | Build/test du projet impacte | +| Ajouter un test smoke | Copilot (+Explore) | Route/endpoint, projet de test cible | Test + commande cible | Execution test cible | +| Corriger une regression | Plan + Copilot | Symptome, zone suspecte, test attendu | Fix + test NonRegression | Test rouge avant, vert apres | +| Diagnostiquer flux PostIt/OIDC | Explore + Plan | Flux, symptome, plateforme | Carte du flux + hypotheses testables | Verification manuelle + tests existants | + +## Regles d'arbitrage + +- Si l'intention est "comprendre": commencer par Explore. +- Si l'intention est "orchestrer": commencer par Plan. +- Si l'intention est "produire": utiliser Copilot apres cadrage. +- Si une sortie n'inclut pas de preuve, elle est incomplete. diff --git a/doc/agent-playbook.md b/doc/agent-playbook.md new file mode 100644 index 00000000..ecc14f1d --- /dev/null +++ b/doc/agent-playbook.md @@ -0,0 +1,101 @@ +# Playbook d'usage des agents IA (Yavsc) + +Ce playbook normalise l'usage de Copilot, Plan et Explore dans le depot. +Il privilegie des sorties verifiables: fichiers, commandes tests, risques. + +## Quand utiliser quel agent + +- Plan: quand la tache est ambigue, transverse ou risquee. +- Explore: quand il faut cartographier rapidement des zones du code. +- Copilot: quand les specifications sont claires et localisees. + +## Prompt type (base) + +Utiliser ce squelette avant toute tache non triviale: + +```text +Contexte: +Objectif: +Contraintes: +Verification: +Sortie attendue: +``` + +## 4 scenarios de reference + +## 1) Explorer un bounded context + +Intention: +- Comprendre ou implementer un changement dans un BC sans regression laterale. + +Prompt minimal: +```text +Explore le BC avec profondeur medium. +Retour: composants touches, points d'entree, tests existants et risques. +``` + +Preuves attendues: +- Carte des fichiers a modifier. +- Test(s) smoke/mandatory proposes. + +## 2) Ajouter un smoke test + +Intention: +- Couvrir rapidement un endpoint ou une route publique. + +Prompt minimal: +```text +Propose un smoke test pour dans le projet de test approprie. +Respecte les conventions de doc/testing.md. +``` + +Preuves attendues: +- Fichier test cree/modifie. +- Commande precise pour executer le test cible. + +## 3) Corriger une regression backend API + +Intention: +- Corriger un bug sans casser un flux voisin. + +Prompt minimal: +```text +Planifie puis implemente un fix de dans . +Ajoute/ajuste un test NonRegression rouge puis vert. +``` + +Preuves attendues: +- Explication cause racine. +- Test non-regression associe. +- Commande d'execution et resultat attendu. + +## 4) Tracer un flux PostIt/OIDC + +Intention: +- Localiser une cassure d'authentification entre client et serveur. + +Prompt minimal: +```text +Cartographie le flux OIDC PostIt: entrypoints, callback, stockage token, +refresh. Donne points de rupture probables et tests/verification proposes. +``` + +Preuves attendues: +- Liste ordonnee des etapes du flux. +- Fichiers critiques. +- Hypotheses testables. + +## Anti-patterns a eviter + +- Prompt sans objectif verifiable. +- Demande trop large sans perimetre de fichiers. +- Validation basee uniquement sur "ca semble correct". +- Pas de lien entre changement et niveau de test. + +## Gate PR minimale (agent-assiste) + +Avant validation: +- Impact architecture explicite. +- Rationale de choix agent explicite. +- Test(s) executes et justifies. +- Risques residuels documentes. diff --git a/doc/onboarding-agents.md b/doc/onboarding-agents.md new file mode 100644 index 00000000..14a727a1 --- /dev/null +++ b/doc/onboarding-agents.md @@ -0,0 +1,73 @@ +# Onboarding guide: agents IA + architecture + tests + +Ce guide est optimise pour accelerer la prise en main des agents IA +(Copilot, Plan, Explore) dans Yavsc, avec une verification rapide +par les tests. + +## Resultat attendu + +A la fin du parcours, un contributeur doit pouvoir: +- Identifier les projets impactes par une modification. +- Choisir l'agent adapte a l'intention de travail. +- Produire une proposition de changement verifiable par les tests. + +## Parcours en 3 modules + +## Module A - Comprendre le terrain (30-45 min) + +Objectif: acquerir une lecture fiable de l'architecture. + +1. Lire [README.md](../README.md) puis [Architecture.md](Architecture.md). +2. Lire [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md). +3. Selon le domaine: + - Backend/API: [architecture/workflow-multi-parties.md](architecture/workflow-multi-parties.md) + - PostIt: [architecture/postit.md](architecture/postit.md) puis [architecture/postit-oidc.md](architecture/postit-oidc.md) + +Definition of done: +- Expliquer en 5 phrases quelles couches sont touchees. +- Citer le ou les points d'entree applicatifs a verifier. + +## Module B - Boucle tests rapide (20-30 min) + +Objectif: verifier rapidement sans lancer toute la suite. + +1. Lire [testing.md](testing.md). +2. Lancer les smoke tests d'abord, puis mandatory selon le projet. +3. N'elargir au test complet que si le scope depasse le BC touche. + +Definition of done: +- Fournir la commande test executee. +- Expliquer pourquoi ce niveau de test est suffisant. + +## Module C - Usage agentique en production (30-40 min) + +Objectif: utiliser les agents comme accelerateurs, pas comme boites noires. + +1. Plan: decomposer la tache en etapes verifiables. +2. Explore: collecter le contexte code/doc precise. +3. Copilot: implementer localement et verifier. + +Regles: +- Toujours donner un contexte explicite (fichier, but, contrainte). +- Demander des preuves observables (fichiers modifies, tests, risques). +- Refuser toute sortie non verifiable. + +Definition of done: +- Une tache simple est livree avec: + - Plan + - Changement local + - Preuve par test + +## Routine continue (sans echeance fixe) + +Rituels recommandes: +- Hebdo: revue des prompts qui ont bien fonctionne. +- Mensuel: mise a jour du present guide et du playbook. +- A chaque incident: ajouter un anti-pattern dans le playbook. + +## Check-list de validation + +- Le changement indique son impact architecture. +- Le choix de l'agent est justifie. +- La preuve test est incluse. +- Les risques residuels sont explicitement listes. diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index c2fab4c3..3f602fc4 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -72,9 +72,9 @@ public class ActivitiesPageViewModelTests { Paths.Add(path); - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) { - var activities = new List + var activities = new List { new() { @@ -82,11 +82,11 @@ public class ActivitiesPageViewModelTests Name = "Brush", Description = "Coiffure à domicile", PerformerCount = 1, - Forms = new List + Forms = new List { new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" } }, - Children = new List + Children = new List { new() { @@ -95,7 +95,7 @@ public class ActivitiesPageViewModelTests Description = "Spécialisation premium", ParentCode = "brush", PerformerCount = 1, - Forms = new List + Forms = new List { new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" } } @@ -106,14 +106,14 @@ public class ActivitiesPageViewModelTests return Task.FromResult((T)(object)activities); } - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) { var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal) - ? new List + ? new List { new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 } } - : new List + : new List { new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 } }; diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index d80d3195..c7b5b926 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -1,7 +1,8 @@ -using System.Net.Http; using System.Text.Json; +using PostIt.Helpers; using PostIt.Services; using PostIt.ViewModels; +using PostIt.ViewModels.Commands; using Yavsc; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; @@ -16,19 +17,19 @@ public class BillingCommandPageViewModelTests { var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, - client) - { - EventDateText = "2026-09-02 14:30", - Reason = "Point de cadrage", - Address = "1 rue du Test", - LatitudeText = "48.8566", - LongitudeText = "2.3522", - Consent = true, - }; + client) as RdvViewModel; + + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Reason = "Point de cadrage"; + vm!.Address = "1 rue du Test"; + vm!.Latitude = 48.8566; + vm!.Longitude = 2.3522; + vm!.Consent = true; await vm.SubmitCommand.ExecuteAsync(null); @@ -47,16 +48,15 @@ public class BillingCommandPageViewModelTests { var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "book", Name = "Book" }, + + var vm = + new CommandFormSummary { Id = 13, ActionName = "Book", Title = "Réservation" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "book", Name = "Book" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Book", Title = "Réservation" }, client); - await vm.SubmitCommand.ExecuteAsync(null); - - Assert.Null(api.LastPath); - Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(vm); } [Fact] @@ -64,19 +64,19 @@ public class BillingCommandPageViewModelTests { var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + var vm = + + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, - client) - { - EventDateText = "2026-09-02 14:30", - Reason = "Point de cadrage", - Address = "1 rue du Test", - LatitudeText = string.Empty, - LongitudeText = string.Empty, - Consent = true, - }; + client) as RdvViewModel; + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Reason = "Point de cadrage"; + vm!.Address = "1 rue du Test"; + vm!.Latitude = null; + vm!.Longitude = null; + vm!.Consent = true; await vm.SubmitCommand.ExecuteAsync(null); @@ -97,16 +97,17 @@ public class BillingCommandPageViewModelTests var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, - client); + client) as RdvViewModel; - await vm.UseCurrentLocationCommand.ExecuteAsync(null); + await vm!.UseCurrentLocationCommand.ExecuteAsync(null); - Assert.Equal("48.8566", vm.LatitudeText); - Assert.Equal("2.3522", vm.LongitudeText); + Assert.Equal(48.8566, vm!.Latitude); + Assert.Equal(2.3522, vm!.Longitude); } finally { @@ -126,19 +127,18 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, - client) - { - EventDateText = "2026-09-02 14:30", - Address = "1 rue du Test", - LatitudeText = "48.8566", - LongitudeText = "2.3522", - Consent = true, - AdditionalInfo = "Prévoir shampoing", - }; + client) as BrushViewModel; + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Address = "1 rue du Test"; + vm!.Latitude = 48.8566; + vm!.Longitude = 2.3522; + vm!.Consent = true; + vm!.AdditionalInfo = "Prévoir shampoing"; await vm.InitializeAsync(); vm.SelectedPrestation = vm.AvailablePrestations[1]; @@ -162,23 +162,22 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "mbrush", Name = "MBrush" }, + var vm = + new CommandFormSummary { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "mbrush", Name = "MBrush" }, new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" }, - new CommandFormSummaryDto { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }, - client) - { - EventDateText = "2026-09-03 10:00", - Address = "2 rue du Test", - LatitudeText = "48.8567", - LongitudeText = "2.3523", - Consent = true, - }; + client) as MBrushViewModel; + vm!.EventDate = DateTime.Parse("2026-09-03 10:00"); + vm!.Address = "2 rue du Test"; + vm!.Latitude = 48.8567; + vm!.Longitude = 2.3523; + vm!.Consent = true; await vm.InitializeAsync(); - vm.MultiPrestations[0].IsSelected = true; - vm.MultiPrestations[1].IsSelected = true; - await vm.SubmitCommand.ExecuteAsync(null); + vm!.MultiPrestations[0].IsSelected = true; + vm!.MultiPrestations[1].IsSelected = true; + await vm!.SubmitCommand.ExecuteAsync(null); Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath); using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); @@ -200,13 +199,13 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, - client); - - await vm.InitializeAsync(new BillingQueryDetailsDto + client) as BrushViewModel; + await vm!.InitializeAsync(new BillingQueryDetailsDto { Id = 77, BillingCode = "Brush", @@ -226,9 +225,9 @@ public class BillingCommandPageViewModelTests } }); - vm.SelectedPrestation = vm.AvailablePrestations[1]; - vm.AdditionalInfo = "Note mise à jour"; - await vm.SubmitCommand.ExecuteAsync(null); + vm!.SelectedPrestation = vm!.AvailablePrestations[1]; + vm!.AdditionalInfo = "Note mise à jour"; + await vm!.SubmitCommand.ExecuteAsync(null); Assert.Equal(HttpMethod.Put, api.LastMethod); Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath); diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs index a5d37532..a7f2b084 100644 --- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -14,9 +14,9 @@ public class BillingQueriesPageViewModelTests var api = new StubBillingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); var vm = new BillingQueriesPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, client); await vm.InitializeAsync(); @@ -33,9 +33,9 @@ public class BillingQueriesPageViewModelTests var api = new StubBillingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); var vm = new BillingQueriesPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, client, isReadOnly: true, ongoingOnly: true); @@ -131,4 +131,4 @@ public class BillingQueriesPageViewModelTests public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/Helpers/FormHelpers.cs b/src/PostIt/PostIt/Helpers/FormHelpers.cs new file mode 100644 index 00000000..18cc8fd0 --- /dev/null +++ b/src/PostIt/PostIt/Helpers/FormHelpers.cs @@ -0,0 +1,50 @@ +using System; +using PostIt.ViewModels; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Helpers; + +public static class FormHelpers +{ + public static BillingCommandPageViewModel? + CreateCommandPageViewModel( + this CommandFormSummary form, + ActivityInfo activity, + ActivityUserDisplayItem performer, + BillingApiClient billingClient) + { + + string namespacePrefix = typeof(PostIt.ViewModels.Commands.RdvViewModel).Namespace + "."; + + string formVMName = form.ActionName + "ViewModel"; + + string formOnActivityVMName = activity.Code + formVMName + "ViewModel"; + + var vmType = Type.GetType(namespacePrefix +formOnActivityVMName); + if (vmType == null) + { + vmType = Type.GetType(namespacePrefix + formVMName); + } + if (vmType == null) + { + Console.Error.WriteLine( + $"! Cannot find type '{formOnActivityVMName}' or '{formVMName}'"); + return null; + } + if (!typeof(BillingCommandPageViewModel).IsAssignableFrom(vmType)) + { + Console.Error.WriteLine($"! The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel"); + return null; + } + + var vm = Activator.CreateInstance(vmType, activity, performer, form, billingClient); + + if (vm == null) + { + throw new InvalidOperationException($"Cannot create instance of '{formOnActivityVMName}' or '{formVMName}'"); + } + + return vm as BillingCommandPageViewModel ?? throw new InvalidOperationException($"The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel"); + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index e86d1c4c..ff532b2b 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.Commands; using Yavsc.Api.Client; namespace PostIt.Helpers; @@ -49,7 +50,8 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // ViewModels services.AddSingleton(settings); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 2fd51633..0055a9e6 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -4,7 +4,9 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Microsoft.Extensions.DependencyInjection; using PostIt.ViewModels; +using PostIt.ViewModels.Commands; using PostIt.Views; +using PostIt.Views.Commands; namespace PostIt; @@ -41,8 +43,8 @@ public class ViewLocator : IDataTemplate HomePageViewModel => services.GetRequiredService(), ActivitiesPageViewModel => services.GetRequiredService(), CommandFormsPageViewModel => services.GetRequiredService(), - BillingCommandPageViewModel => services.GetRequiredService(), - BillingQueriesPageViewModel => services.GetRequiredService(), + BrushViewModel => services.GetRequiredService(), + RdvViewModel => services.GetRequiredService(), SignaturePageViewModel => services.GetRequiredService(), AddCircleMemberDialogViewModel => services.GetRequiredService(), CirclesPageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs index 5268d07a..2c776d16 100644 --- a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs @@ -20,16 +20,16 @@ public partial class ActivitiesPageViewModel : ViewModelBase private bool _syncingSelection; [ObservableProperty] - public partial ObservableCollection Activities { get; set; } = new(); + public partial ObservableCollection Activities { get; set; } = new(); [ObservableProperty] - public partial ActivityBrowseItemDto? SelectedActivity { get; set; } + public partial ActivityInfo? SelectedActivity { get; set; } [ObservableProperty] - public partial ObservableCollection Specializations { get; set; } = new(); + public partial ObservableCollection Specializations { get; set; } = new(); [ObservableProperty] - public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; } + public partial ActivityInfo? SelectedSpecialization { get; set; } [ObservableProperty] public partial ObservableCollection Performers { get; set; } = new(); @@ -43,7 +43,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase [ObservableProperty] public partial string StatusMessage { get; set; } = "Choisissez une activité."; - public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity; + public ActivityInfo? CurrentActivity => SelectedSpecialization ?? SelectedActivity; public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)"; public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)"; public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0; @@ -66,19 +66,19 @@ public partial class ActivitiesPageViewModel : ViewModelBase _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); } - partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value) + partial void OnSelectedActivityChanged(ActivityInfo? value) { if (_syncingSelection) return; _ = ShowActivitySafeAsync(value); } - partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value) + partial void OnSelectedSpecializationChanged(ActivityInfo? value) { if (_syncingSelection) return; _ = ShowSpecializationSafeAsync(value); } - private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value) + private async Task ShowActivitySafeAsync(ActivityInfo? value) { try { @@ -94,7 +94,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase } } - private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value) + private async Task ShowSpecializationSafeAsync(ActivityInfo? value) { try { @@ -117,7 +117,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase try { var list = await _client.GetCatalogAsync(); - Activities = new ObservableCollection(list ?? new()); + Activities = new ObservableCollection(list ?? new()); var first = Activities.FirstOrDefault(); await ShowActivityAsync(first); @@ -128,15 +128,15 @@ public partial class ActivitiesPageViewModel : ViewModelBase } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { - Activities = new ObservableCollection(); - Specializations = new ObservableCollection(); + Activities = new ObservableCollection(); + Specializations = new ObservableCollection(); Performers = new ObservableCollection(); StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; } catch (Exception ex) { - Activities = new ObservableCollection(); - Specializations = new ObservableCollection(); + Activities = new ObservableCollection(); + Specializations = new ObservableCollection(); Performers = new ObservableCollection(); StatusMessage = $"Erreur: {ex.Message}"; } @@ -146,7 +146,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase } } - public async Task ShowActivityAsync(ActivityBrowseItemDto? activity) + public async Task ShowActivityAsync(ActivityInfo? activity) { _syncingSelection = true; try @@ -163,7 +163,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase OnPropertyChanged(nameof(SelectedActivityLabel)); OnPropertyChanged(nameof(CurrentActivityLabel)); OnPropertyChanged(nameof(CurrentFormCount)); - Specializations = new ObservableCollection(activity?.Children ?? new()); + Specializations = new ObservableCollection(activity?.Children ?? new()); if (activity is null) { @@ -175,7 +175,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase await LoadPerformersAsync(activity); } - public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization) + public async Task ShowSpecializationAsync(ActivityInfo? specialization) { _syncingSelection = true; try @@ -203,7 +203,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase await LoadPerformersAsync(specialization); } - private async Task LoadPerformersAsync(ActivityBrowseItemDto activity) + private async Task LoadPerformersAsync(ActivityInfo activity) { IsBusy = true; try diff --git a/src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs b/src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs index ee06b883..3e4426b4 100644 --- a/src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs +++ b/src/PostIt/PostIt/ViewModels/ActivityUserDisplayItem.cs @@ -17,7 +17,7 @@ public sealed class ActivityUserDisplayItem public int ExtraActivityCount { get; init; } public string ExtraActivityLabel { get; init; } = "Pas d'autre activité"; - public static ActivityUserDisplayItem FromDto(ActivityPerformerDto dto) + public static ActivityUserDisplayItem FromDto(PerformerActivity dto) { return new ActivityUserDisplayItem { diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs deleted file mode 100644 index 2ffeec26..00000000 --- a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs +++ /dev/null @@ -1,490 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; -using System.Net; -using System.Net.Http; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using PostIt.Services; -using Yavsc; -using Yavsc.Abstract.Workflow; -using Yavsc.Api.Client; -using Yavsc.Models.Billing; -using Yavsc.Models.Haircut; - -namespace PostIt.ViewModels; - -public partial class BillingCommandPageViewModel : ViewModelBase -{ - private readonly BillingApiClient _billingClient; - - public ActivityBrowseItemDto Activity { get; } - public ActivityUserDisplayItem Performer { get; } - public CommandFormSummaryDto Form { get; } - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } - - [ObservableProperty] - public partial string EventDateText { get; set; } - - [ObservableProperty] - public partial string Reason { get; set; } = string.Empty; - - [ObservableProperty] - public partial string Address { get; set; } = string.Empty; - - [ObservableProperty] - public partial string LatitudeText { get; set; } = string.Empty; - - [ObservableProperty] - public partial string LongitudeText { get; set; } = string.Empty; - - [ObservableProperty] - public partial bool Consent { get; set; } = true; - - [ObservableProperty] - public partial ObservableCollection AvailablePrestations { get; set; } = new(); - - [ObservableProperty] - public partial HairPrestationDto? SelectedPrestation { get; set; } - - [ObservableProperty] - public partial ObservableCollection MultiPrestations { get; set; } = new(); - - [ObservableProperty] - public partial string AdditionalInfo { get; set; } = string.Empty; - - [ObservableProperty] - public partial long? ExistingQueryId { get; set; } - - [ObservableProperty] - public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted; - - public bool CanUseCurrentLocation => IsSupported && !IsBusy; - - public string Title => Form.Title; - public string PerformerLabel => Performer.UserName; - public string ActivityLabel => Activity.Name; - public bool IsSupported => IsRdv || IsBrush || IsMultiBrush; - public bool IsRdv => string.Equals(Form.ActionName, BillingCodes.Rdv, StringComparison.Ordinal); - public bool IsBrush => string.Equals(Form.ActionName, BillingCodes.Brush, StringComparison.Ordinal); - public bool IsMultiBrush => string.Equals(Form.ActionName, BillingCodes.MBrush, StringComparison.Ordinal); - public bool ShowsReason => IsRdv; - public bool ShowsAdditionalInfo => IsBrush; - public bool ShowsSinglePrestation => IsBrush; - public bool ShowsMultiplePrestations => IsMultiBrush; - public string BillingRoute => $"/billing/{Form.ActionName}"; - public bool IsEditingExisting => ExistingQueryId.HasValue; - public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande"; - public string SupportMessage => IsSupported - ? IsRdv - ? "Complétez les informations du rendez-vous puis postez la commande." - : IsBrush - ? "Choisissez une prestation coiffure puis postez la commande." - : "Choisissez une ou plusieurs prestations coiffure puis postez la commande." - : $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt."; - - public override bool CanNavigateNext - { - get => false; - protected set { _ = value; } - } - - public override bool CanNavigatePrevious - { - get => true; - protected set { _ = value; } - } - - public BillingCommandPageViewModel( - ActivityBrowseItemDto activity, - ActivityUserDisplayItem performer, - CommandFormSummaryDto form, - BillingApiClient billingClient) - { - Activity = activity ?? throw new ArgumentNullException(nameof(activity)); - Performer = performer ?? throw new ArgumentNullException(nameof(performer)); - Form = form ?? throw new ArgumentNullException(nameof(form)); - _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); - - EventDateText = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); - StatusMessage = SupportMessage; - } - - partial void OnExistingQueryIdChanged(long? value) - { - OnPropertyChanged(nameof(IsEditingExisting)); - OnPropertyChanged(nameof(SubmitLabel)); - } - - partial void OnIsBusyChanged(bool value) - { - OnPropertyChanged(nameof(CanUseCurrentLocation)); - UseCurrentLocationCommand.NotifyCanExecuteChanged(); - } - - public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) - { - if (!IsBrush && !IsMultiBrush) - { - if (existingQuery is not null) - { - ApplyExistingQuery(existingQuery); - } - - return; - } - - IsBusy = true; - try - { - var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName).ConfigureAwait(true); - AvailablePrestations = new ObservableCollection(prestations ?? new List()); - SelectedPrestation = AvailablePrestations.FirstOrDefault(); - MultiPrestations = new ObservableCollection(AvailablePrestations.Select(SelectableHairPrestationItem.FromDto)); - - StatusMessage = AvailablePrestations.Count == 0 - ? "Aucune prestation coiffure disponible." - : SupportMessage; - } - catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - { - StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous."; - } - catch (Exception ex) - { - StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}"; - } - finally - { - IsBusy = false; - } - - if (existingQuery is not null) - { - ApplyExistingQuery(existingQuery); - } - } - - [RelayCommand] - private async Task SubmitAsync() - { - if (!IsSupported) - { - StatusMessage = SupportMessage; - return; - } - - if (!Consent) - { - StatusMessage = "Le consentement est requis pour poster la commande."; - return; - } - - if (!TryParseEventDate(out var eventDate)) - { - StatusMessage = "La date doit être saisie au format yyyy-MM-dd HH:mm."; - return; - } - - if (IsRdv && string.IsNullOrWhiteSpace(Reason)) - { - StatusMessage = "Le motif du rendez-vous est requis."; - return; - } - - if (string.IsNullOrWhiteSpace(Address)) - { - StatusMessage = "L'adresse du rendez-vous est requise."; - return; - } - - if (!TryParseCoordinates(out var latitude, out var longitude, out var coordinateError)) - { - StatusMessage = coordinateError; - return; - } - - IsBusy = true; - try - { - var address = Address.Trim(); - var locationPayload = BuildLocationPayload(address, latitude, longitude); - - var payload = new BillingQueryDetailsDto - { - Id = ExistingQueryId ?? 0, - BillingCode = Form.ActionName, - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent = Consent, - EventDate = eventDate, - Status = CommandStatus, - Reason = Reason.Trim(), - AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), - Location = new BillingLocationDto - { - Address = address, - Latitude = latitude, - Longitude = longitude, - } - }; - - if (IsRdv) - { - if (IsEditingExisting) - { - await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); - } - else - { - await _billingClient.CreateAsync(Form.ActionName, new - { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = eventDate, - Location = locationPayload, - Reason = payload.Reason, - Status = payload.Status, - }).ConfigureAwait(true); - } - } - else if (IsBrush) - { - if (SelectedPrestation is null) - { - StatusMessage = "Sélectionnez une prestation coiffure."; - return; - } - - payload.PrestationId = SelectedPrestation.Id; - - if (IsEditingExisting) - { - await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); - } - else - { - await _billingClient.CreateAsync(Form.ActionName, new - { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = (DateTime?)eventDate, - Location = locationPayload, - PrestationId = SelectedPrestation.Id, - AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), - Status = payload.Status, - }).ConfigureAwait(true); - } - } - else if (IsMultiBrush) - { - var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList(); - if (selectedPrestations.Count == 0) - { - StatusMessage = "Sélectionnez au moins une prestation coiffure."; - return; - } - - payload.PrestationIds = selectedPrestations.Select(x => x.Id).ToList(); - - if (IsEditingExisting) - { - await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); - } - else - { - await _billingClient.CreateAsync(Form.ActionName, new - { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = eventDate, - Location = locationPayload, - Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), - Status = payload.Status, - }).ConfigureAwait(true); - } - } - - StatusMessage = IsEditingExisting - ? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}." - : $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; - } - catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - { - StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; - } - catch (Exception ex) - { - StatusMessage = $"Erreur lors de l'envoi: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand(CanExecute = nameof(CanUseCurrentLocation))] - private async Task UseCurrentLocationAsync() - { - if (!CanUseCurrentLocation) - { - return; - } - - IsBusy = true; - try - { - var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true); - if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue) - { - StatusMessage = result.Message; - return; - } - - LatitudeText = result.Latitude.Value.ToString(CultureInfo.InvariantCulture); - LongitudeText = result.Longitude.Value.ToString(CultureInfo.InvariantCulture); - StatusMessage = string.IsNullOrWhiteSpace(Address) - ? "Position récupérée. Complétez l'adresse puis envoyez la commande." - : result.Message; - } - catch (OperationCanceledException) - { - StatusMessage = "La récupération de la position a été annulée."; - } - catch (Exception ex) - { - StatusMessage = $"Impossible de récupérer la position: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) - { - ExistingQueryId = existingQuery.Id; - CommandStatus = existingQuery.Status; - Consent = existingQuery.Consent; - Reason = existingQuery.Reason ?? string.Empty; - AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty; - - if (existingQuery.EventDate is not null) - { - EventDateText = existingQuery.EventDate.Value - .ToLocalTime() - .ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); - } - - if (existingQuery.Location is not null) - { - Address = existingQuery.Location.Address ?? string.Empty; - LatitudeText = existingQuery.Location.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; - LongitudeText = existingQuery.Location.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; - } - - if (IsBrush && existingQuery.PrestationId is not null) - { - SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value); - } - - if (IsMultiBrush) - { - var selectedIds = existingQuery.PrestationIds is null - ? new HashSet() - : new HashSet(existingQuery.PrestationIds); - foreach (var item in MultiPrestations) - { - item.IsSelected = selectedIds.Contains(item.Id); - } - } - - StatusMessage = $"Commande #{existingQuery.Id} chargée."; - } - - private bool TryParseEventDate(out DateTime eventDate) - { - return DateTime.TryParse( - EventDateText, - CultureInfo.CurrentCulture, - DateTimeStyles.AssumeLocal, - out eventDate) - || DateTime.TryParse( - EventDateText, - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeLocal, - out eventDate); - } - - private static bool TryParseCoordinate(string text, out double value) - { - return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, out value) - || double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); - } - - private static object BuildLocationPayload(string address, double? latitude, double? longitude) - { - if (latitude.HasValue && longitude.HasValue) - { - return new - { - Address = address, - Latitude = latitude.Value, - Longitude = longitude.Value, - }; - } - - return new - { - Address = address, - }; - } - - private bool TryParseCoordinates(out double? latitude, out double? longitude, out string error) - { - latitude = null; - longitude = null; - error = string.Empty; - - var latitudeMissing = string.IsNullOrWhiteSpace(LatitudeText); - var longitudeMissing = string.IsNullOrWhiteSpace(LongitudeText); - - if (latitudeMissing && longitudeMissing) - { - return true; - } - - if (latitudeMissing != longitudeMissing) - { - error = "Latitude et longitude doivent être renseignées ensemble, ou laissées vides toutes les deux."; - return false; - } - - if (!TryParseCoordinate(LatitudeText, out var parsedLatitude)) - { - error = "Latitude invalide."; - return false; - } - - if (!TryParseCoordinate(LongitudeText, out var parsedLongitude)) - { - error = "Longitude invalide."; - return false; - } - - latitude = parsedLatitude; - longitude = parsedLongitude; - return true; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs index d680ba74..d5aba021 100644 --- a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs @@ -18,9 +18,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase { private readonly BillingApiClient _billingClient; - public ActivityBrowseItemDto Activity { get; } + public ActivityInfo Activity { get; } public ActivityUserDisplayItem Performer { get; } - public CommandFormSummaryDto Form { get; } + public CommandFormSummary Form { get; } public bool IsReadOnly { get; } public bool OngoingOnly { get; } @@ -55,9 +55,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase } public BillingQueriesPageViewModel( - ActivityBrowseItemDto activity, + ActivityInfo activity, ActivityUserDisplayItem performer, - CommandFormSummaryDto form, + CommandFormSummary form, BillingApiClient billingClient, bool isReadOnly = false, bool ongoingOnly = false) @@ -133,8 +133,8 @@ public partial class BillingQueriesPageViewModel : ViewModelBase try { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); - var vm = new BillingCommandPageViewModel(Activity, Performer, Form, _billingClient); - await vm.InitializeAsync(details).ConfigureAwait(true); + var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient); + await vm!.InitializeAsync(details).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) @@ -170,4 +170,4 @@ public partial class BillingQueriesPageViewModel : ViewModelBase private static bool IsOngoingStatus(QueryStatus status) => status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs index e1453c11..71f5d67a 100644 --- a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs @@ -15,14 +15,14 @@ public partial class CommandFormsPageViewModel : ViewModelBase { private readonly BillingApiClient _billingClient; - public ActivityBrowseItemDto Activity { get; } + public ActivityInfo Activity { get; } public ActivityUserDisplayItem Performer { get; } [ObservableProperty] - public partial ObservableCollection Forms { get; set; } + public partial ObservableCollection Forms { get; set; } [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))] - public partial CommandFormSummaryDto? SelectedForm { get; set; } + public partial CommandFormSummary? SelectedForm { get; set; } [ObservableProperty] public partial string StatusMessage { get; set; } @@ -43,7 +43,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase } public CommandFormsPageViewModel( - ActivityBrowseItemDto activity, + ActivityInfo activity, ActivityUserDisplayItem performer, BillingApiClient billingClient) { @@ -51,7 +51,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase Performer = performer ?? throw new ArgumentNullException(nameof(performer)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); - Forms = new ObservableCollection((activity.Forms ?? new()) + Forms = new ObservableCollection((activity.Forms ?? new()) .OrderBy(f => f.Title) .ThenBy(f => f.ActionName)); SelectedForm = Forms.FirstOrDefault(); @@ -81,8 +81,9 @@ public partial class CommandFormsPageViewModel : ViewModelBase throw new InvalidOperationException("Application PostIt indisponible."); } - var vm = new BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient); - await vm.InitializeAsync(); + var vm = SelectedForm.CreateCommandPageViewModel( + Activity, Performer, _billingClient); + await vm!.InitializeAsync(); await app.PushPageAsync(vm); } @@ -131,4 +132,4 @@ public partial class CommandFormsPageViewModel : ViewModelBase await vm.InitializeAsync(); await app.PushPageAsync(vm); } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs new file mode 100644 index 00000000..0ab76291 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; + +namespace PostIt.ViewModels; + +public abstract partial class BillingCommandPageViewModel : RemoteViewModelBase +{ + protected readonly BillingApiClient _billingClient; + + public ActivityInfo Activity { get; } + public ActivityUserDisplayItem Performer { get; } + public CommandFormSummary Form { get; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } + + [ObservableProperty] + public partial string Reason { get; set; } = string.Empty; + + + + [ObservableProperty] + public partial bool Consent { get; set; } = true; + + + [ObservableProperty] + public partial string AdditionalInfo { get; set; } = string.Empty; + + [ObservableProperty] + public partial long? ExistingQueryId { get; set; } + + [ObservableProperty] + public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted; + + public bool CanUseCurrentLocation => IsSupported && !IsBusy; + + public string Title => Form.Title; + public string PerformerLabel => Performer.UserName; + public string ActivityLabel => Activity.Name; + public virtual bool IsSupported => true; + public string BillingRoute => $"/billing/{Form.ActionName}"; + public bool IsEditingExisting => ExistingQueryId.HasValue; + public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande"; + public virtual string SupportMessage => $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt."; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public BillingCommandPageViewModel( + ActivityInfo activity, + ActivityUserDisplayItem performer, + CommandFormSummary form, + BillingApiClient billingClient) + { + Activity = activity ?? throw new ArgumentNullException(nameof(activity)); + Performer = performer ?? throw new ArgumentNullException(nameof(performer)); + Form = form ?? throw new ArgumentNullException(nameof(form)); + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + + StatusMessage = SupportMessage; + } + + partial void OnExistingQueryIdChanged(long? value) + { + OnPropertyChanged(nameof(IsEditingExisting)); + OnPropertyChanged(nameof(SubmitLabel)); + } + + partial void OnIsBusyChanged(bool value) + { + OnPropertyChanged(nameof(CanUseCurrentLocation)); + } + + public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) + { + await LoadAsync(); + if (existingQuery is not null) + { + ApplyExistingQuery(existingQuery); + return; + } + } + + protected abstract void ApplyExistingQuery(BillingQueryDetailsDto existingQuery); + + + + [RelayCommand] + protected abstract Task SubmitAsync(); +} diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs new file mode 100644 index 00000000..986b046e --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs @@ -0,0 +1,164 @@ +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 Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Haircut; +namespace PostIt.ViewModels.Commands; +public partial class BrushViewModel : RdvViewModel +{ + public override string SupportMessage => "Choisissez une prestation coiffure puis postez la commande."; + + [ObservableProperty] + public partial ObservableCollection AvailablePrestations { get; set; } = new(); + + [ObservableProperty] + public partial HairPrestationDto? SelectedPrestation { get; set; } + + + public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) + : base(activity, performer, form, billingClient) + { + } + + public override async Task LoadAsync() + { + var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName); + + AvailablePrestations = new ObservableCollection + (prestations ?? new List()); + + if (SelectedPrestation is null) + { + SelectedPrestation = AvailablePrestations.FirstOrDefault(); + } + + } + + protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + { + base.ApplyExistingQuery(existingQuery); + + if (existingQuery.PrestationId is not null) + { + SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value); + } + + IsBusy = true; + try + { + if (SelectedPrestation is null) + { + SelectedPrestation = AvailablePrestations.FirstOrDefault(); + } + + StatusMessage = AvailablePrestations.Count == 0 + ? "Aucune prestation coiffure disponible." + : SupportMessage; + } + catch (HttpRequestException ex) + when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + protected override async Task SubmitAsync() + { + if (!Consent) + { + StatusMessage = "Le consentement est requis pour poster la commande."; + return; + } + + if (string.IsNullOrWhiteSpace(Address)) + { + StatusMessage = "L'adresse du rendez-vous est requise."; + return; + } + + if (SelectedPrestation is null) + { + StatusMessage = "Sélectionnez une prestation coiffure."; + return; + } + + IsBusy = true; + try + { + var address = Address.Trim(); + var locationPayload = BuildLocationPayload(address, Latitude, Longitude); + + var payload = new BillingQueryDetailsDto + { + Id = ExistingQueryId ?? 0, + BillingCode = Form.ActionName, + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent = Consent, + EventDate = EventDate, + Status = CommandStatus, + Reason = Reason.Trim(), + AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), + Location = new BillingLocationDto + { + Address = address, + Latitude = Latitude, + Longitude = Longitude, + } + }; + + payload.PrestationId = SelectedPrestation.Id; + + if (IsEditingExisting) + { + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = (DateTime?)EventDate, + Location = locationPayload, + PrestationId = SelectedPrestation.Id, + AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), + Status = payload.Status, + }).ConfigureAwait(true); + } + + StatusMessage = IsEditingExisting + ? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}." + : $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; + } + catch (HttpRequestException ex) + when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}"; + } + finally + { + IsBusy = false; + } + + } +} diff --git a/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel.cs new file mode 100644 index 00000000..793a7751 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel.cs @@ -0,0 +1,130 @@ +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 Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; + +namespace PostIt.ViewModels.Commands; + +public partial class MBrushViewModel : BrushViewModel +{ + public override string SupportMessage => "Choisissez une ou plusieurs prestations coiffure puis postez la commande."; + + [ObservableProperty] + public partial ObservableCollection MultiPrestations { get; set; } = new(); + + public MBrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) + : base(activity, performer, form, billingClient) + { + } + + public override async Task LoadAsync() + { + await base.LoadAsync().ConfigureAwait(true); + MultiPrestations = new ObservableCollection( + AvailablePrestations.Select(SelectableHairPrestationItem.FromDto)); + } + + protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + { + base.ApplyExistingQuery(existingQuery); + + var selectedIds = existingQuery.PrestationIds is null + ? new HashSet() + : new HashSet(existingQuery.PrestationIds); + + foreach (var item in MultiPrestations) + { + item.IsSelected = selectedIds.Contains(item.Id); + } + } + + protected override async Task SubmitAsync() + { + if (!Consent) + { + StatusMessage = "Le consentement est requis pour poster la commande."; + return; + } + + if (string.IsNullOrWhiteSpace(Address)) + { + StatusMessage = "L'adresse du rendez-vous est requise."; + return; + } + + var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList(); + if (selectedPrestations.Count == 0) + { + StatusMessage = "Sélectionnez au moins une prestation coiffure."; + return; + } + + IsBusy = true; + try + { + var address = Address.Trim(); + var locationPayload = BuildLocationPayload(address, Latitude, Longitude); + + var payload = new BillingQueryDetailsDto + { + Id = ExistingQueryId ?? 0, + BillingCode = Form.ActionName, + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent = Consent, + EventDate = EventDate, + Status = CommandStatus, + Reason = Reason.Trim(), + Location = new BillingLocationDto + { + Address = address, + Latitude = Latitude, + Longitude = Longitude, + }, + PrestationIds = selectedPrestations.Select(x => x.Id).ToList(), + }; + + if (IsEditingExisting) + { + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = EventDate, + Location = locationPayload, + Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), + Status = payload.Status, + }).ConfigureAwait(true); + } + + StatusMessage = IsEditingExisting + ? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}." + : $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; + } + catch (HttpRequestException ex) + when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs new file mode 100644 index 00000000..c2ff3294 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs @@ -0,0 +1,211 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels.Commands; + +public partial class RdvViewModel : BillingCommandPageViewModel +{ + public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande."; + + [ObservableProperty] + public partial string Address { get; set; } = string.Empty; + + [ObservableProperty] + public partial double? Latitude { get; set; } + + [ObservableProperty] + public partial double? Longitude { get; set; } + + + [ObservableProperty] + public partial DateTime EventDate { get; set; } + + public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) + : base(activity, performer, form, billingClient) + { + EventDate = DateTime.Now.AddDays(1); + } + + protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + { + ExistingQueryId = existingQuery.Id; + CommandStatus = existingQuery.Status; + Consent = existingQuery.Consent; + Reason = existingQuery.Reason ?? string.Empty; + AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty; + + if (existingQuery.EventDate is not null) + { + EventDate = existingQuery.EventDate.Value + .ToLocalTime(); + } + + if (existingQuery.Location is not null) + { + Address = existingQuery.Location.Address ?? string.Empty; + Latitude = existingQuery.Location.Latitude; + Longitude = existingQuery.Location.Longitude; + } + + StatusMessage = $"Commande #{existingQuery.Id} chargée."; + } + + [RelayCommand(CanExecute = nameof(CanUseCurrentLocation))] + private async Task UseCurrentLocationAsync() + { + if (!CanUseCurrentLocation) + { + return; + } + + IsBusy = true; + try + { + var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true); + if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue) + { + StatusMessage = result.Message; + return; + } + + Latitude = result.Latitude.Value; + Longitude = result.Longitude.Value; + StatusMessage = string.IsNullOrWhiteSpace(Address) + ? "Position récupérée. Complétez l'adresse puis envoyez la commande." + : result.Message; + } + catch (OperationCanceledException) + { + StatusMessage = "La récupération de la position a été annulée."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de récupérer la position: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + protected static object BuildLocationPayload(string address, double? latitude, double? longitude) + { + if (latitude.HasValue && longitude.HasValue) + { + return new + { + Address = address, + Latitude = latitude.Value, + Longitude = longitude.Value, + }; + } + + return new + { + Address = address, + }; + } + + + protected override async Task SubmitAsync() + { + if (!IsSupported) + { + StatusMessage = SupportMessage; + return; + } + + if (!Consent) + { + StatusMessage = "Le consentement est requis pour poster la commande."; + return; + } + + if (string.IsNullOrWhiteSpace(Address)) + { + StatusMessage = "L'adresse du rendez-vous est requise."; + return; + } + + + if (string.IsNullOrWhiteSpace(Reason)) + { + StatusMessage = "Le motif du rendez-vous est requis."; + return; + } + + + + IsBusy = true; + try + { + var address = Address.Trim(); + var locationPayload = BuildLocationPayload(address, Latitude, Longitude); + + var payload = new BillingQueryDetailsDto + { + Id = ExistingQueryId ?? 0, + BillingCode = Form.ActionName, + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent = Consent, + EventDate = EventDate, + Status = CommandStatus, + Reason = Reason.Trim(), + AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), + Location = new BillingLocationDto + { + Address = address, + Latitude = Latitude, + Longitude = Longitude, + } + }; + + if (IsEditingExisting) + { + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = EventDate, + Location = locationPayload, + Reason = payload.Reason, + Status = payload.Status, + }).ConfigureAwait(true); + } + + StatusMessage = IsEditingExisting + ? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}." + : $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors de l'envoi: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + public override Task LoadAsync() + { + return Task.CompletedTask; + } +} diff --git a/src/PostIt/PostIt/ViewModels/RemoteViewModelBase.cs b/src/PostIt/PostIt/ViewModels/RemoteViewModelBase.cs new file mode 100644 index 00000000..0229cca4 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/RemoteViewModelBase.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace PostIt.ViewModels; + +public abstract class RemoteViewModelBase : ViewModelBase +{ + public abstract Task LoadAsync(); + + +} diff --git a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs index 4ca69eea..30783764 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -1,8 +1,8 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace PostIt.ViewModels; -public abstract partial class ViewModelBase : ObservableObject +public abstract class ViewModelBase : ObservableObject { /// /// Gets if the user can navigate to the next page diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml b/src/PostIt/PostIt/Views/ActivitiesPage.axaml index 31d7be5f..fa87051a 100644 --- a/src/PostIt/PostIt/Views/ActivitiesPage.axaml +++ b/src/PostIt/PostIt/Views/ActivitiesPage.axaml @@ -32,7 +32,7 @@ ItemsSource="{Binding Activities}" SelectedItem="{Binding SelectedActivity, Mode=TwoWay}"> - + @@ -53,7 +53,7 @@ ItemsSource="{Binding Specializations}" SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}"> - + diff --git a/src/PostIt/PostIt/Views/BillingCommandPage.axaml b/src/PostIt/PostIt/Views/BillingCommandPage.axaml deleted file mode 100644 index ee8cb61d..00000000 --- a/src/PostIt/PostIt/Views/BillingCommandPage.axaml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -