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/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index 924182f1..c7b5b926 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -24,12 +24,12 @@ public class BillingCommandPageViewModelTests new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, 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; + 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); @@ -56,10 +56,7 @@ public class BillingCommandPageViewModelTests new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, 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] @@ -74,12 +71,12 @@ public class BillingCommandPageViewModelTests new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, 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 = 0; - vm.Longitude = 0; - vm.Consent = true; + 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); @@ -107,10 +104,10 @@ public class BillingCommandPageViewModelTests new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, client) as RdvViewModel; - await vm.UseCurrentLocationCommand.ExecuteAsync(null); + await vm!.UseCurrentLocationCommand.ExecuteAsync(null); - Assert.Equal(48.8566, vm.Latitude); - Assert.Equal(2.3522, vm.Longitude); + Assert.Equal(48.8566, vm!.Latitude); + Assert.Equal(2.3522, vm!.Longitude); } finally { @@ -136,12 +133,12 @@ public class BillingCommandPageViewModelTests new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, 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"; + 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]; @@ -170,17 +167,17 @@ public class BillingCommandPageViewModelTests .CreateCommandPageViewModel( new ActivityInfo { Code = "mbrush", Name = "MBrush" }, new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" }, - client) as PostIt.ViewModels.Commands.BrushViewModel; - 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; + 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)); @@ -208,7 +205,7 @@ public class BillingCommandPageViewModelTests new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, client) as BrushViewModel; - await vm.InitializeAsync(new BillingQueryDetailsDto + await vm!.InitializeAsync(new BillingQueryDetailsDto { Id = 77, BillingCode = "Brush", @@ -228,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/Helpers/FormHelpers.cs b/src/PostIt/PostIt/Helpers/FormHelpers.cs index 44266c10..18cc8fd0 100644 --- a/src/PostIt/PostIt/Helpers/FormHelpers.cs +++ b/src/PostIt/PostIt/Helpers/FormHelpers.cs @@ -7,7 +7,7 @@ namespace PostIt.Helpers; public static class FormHelpers { - public static BillingCommandPageViewModel + public static BillingCommandPageViewModel? CreateCommandPageViewModel( this CommandFormSummary form, ActivityInfo activity, @@ -15,25 +15,30 @@ public static class FormHelpers BillingApiClient billingClient) { - string formVMName = form.ActionName + "ViewModel"; + string namespacePrefix = typeof(PostIt.ViewModels.Commands.RdvViewModel).Namespace + "."; - string formOnActivityVMName = activity.Code + formVMName; + string formVMName = form.ActionName + "ViewModel"; - var vmType = Type.GetType(formOnActivityVMName); + string formOnActivityVMName = activity.Code + formVMName + "ViewModel"; + + var vmType = Type.GetType(namespacePrefix +formOnActivityVMName); if (vmType == null) { - vmType = Type.GetType(formVMName); + vmType = Type.GetType(namespacePrefix + formVMName); } if (vmType == null) { - throw new InvalidOperationException($"Cannot find type '{formOnActivityVMName}' or '{formVMName}'"); + Console.Error.WriteLine( + $"! Cannot find type '{formOnActivityVMName}' or '{formVMName}'"); + return null; } if (!typeof(BillingCommandPageViewModel).IsAssignableFrom(vmType)) { - throw new InvalidOperationException($"The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel"); + Console.Error.WriteLine($"! The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel"); + return null; } - var vm = Activator.CreateInstance(vmType); + var vm = Activator.CreateInstance(vmType, activity, performer, form, billingClient); if (vm == null) { diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 96001115..0055a9e6 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate HomePageViewModel => services.GetRequiredService(), ActivitiesPageViewModel => services.GetRequiredService(), CommandFormsPageViewModel => services.GetRequiredService(), + BrushViewModel => services.GetRequiredService(), RdvViewModel => services.GetRequiredService(), SignaturePageViewModel => services.GetRequiredService(), AddCircleMemberDialogViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs index 7198f2bc..d5aba021 100644 --- a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs @@ -134,7 +134,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient); - await vm.InitializeAsync(details).ConfigureAwait(true); + await vm!.InitializeAsync(details).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs index 7d233971..71f5d67a 100644 --- a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs @@ -83,7 +83,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase var vm = SelectedForm.CreateCommandPageViewModel( Activity, Performer, _billingClient); - await vm.InitializeAsync(); + await vm!.InitializeAsync(); await app.PushPageAsync(vm); } diff --git a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs index 07c33b27..0ab76291 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs @@ -9,7 +9,7 @@ using Yavsc.Models.Billing; namespace PostIt.ViewModels; -public abstract partial class BillingCommandPageViewModel : ViewModelBase +public abstract partial class BillingCommandPageViewModel : RemoteViewModelBase { protected readonly BillingApiClient _billingClient; @@ -46,24 +46,11 @@ public abstract partial class BillingCommandPageViewModel : ViewModelBase 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 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 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 virtual string SupportMessage => $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt."; public override bool CanNavigateNext { @@ -104,7 +91,7 @@ public abstract partial class BillingCommandPageViewModel : ViewModelBase public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) { - + await LoadAsync(); if (existingQuery is not null) { ApplyExistingQuery(existingQuery); diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs index 2cd61b36..986b046e 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs @@ -12,6 +12,7 @@ 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(); @@ -19,42 +20,42 @@ public partial class BrushViewModel : RdvViewModel [ObservableProperty] public partial HairPrestationDto? SelectedPrestation { get; set; } - [ObservableProperty] - public partial ObservableCollection MultiPrestations { get; set; } = new(); - public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) : base(activity, performer, form, billingClient) { } - protected override async void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + public override async Task LoadAsync() { - if (IsBrush && existingQuery.PrestationId is not null) + var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName); + + AvailablePrestations = new ObservableCollection + (prestations ?? new List()); + + if (SelectedPrestation is null) { - SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value); + SelectedPrestation = AvailablePrestations.FirstOrDefault(); } - if (IsMultiBrush) + } + + protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + { + base.ApplyExistingQuery(existingQuery); + + if (existingQuery.PrestationId is not null) { - var selectedIds = existingQuery.PrestationIds is null - ? new HashSet() - : new HashSet(existingQuery.PrestationIds); - foreach (var item in MultiPrestations) - { - item.IsSelected = selectedIds.Contains(item.Id); - } + SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value); } 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)); + if (SelectedPrestation is null) + { + SelectedPrestation = AvailablePrestations.FirstOrDefault(); + } StatusMessage = AvailablePrestations.Count == 0 ? "Aucune prestation coiffure disponible." @@ -77,8 +78,23 @@ public partial class BrushViewModel : RdvViewModel 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 @@ -105,64 +121,30 @@ public partial class BrushViewModel : RdvViewModel } }; - if (IsBrush) + payload.PrestationId = SelectedPrestation.Id; + + if (IsEditingExisting) { - 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); - } + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); } - else if (IsMultiBrush) + else { - var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList(); - if (selectedPrestations.Count == 0) + await _billingClient.CreateAsync(Form.ActionName, new { - 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); - } + 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) diff --git a/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel cs b/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel cs deleted file mode 100644 index c407682a..00000000 --- a/src/PostIt/PostIt/ViewModels/Commands/MBrushViewModel cs +++ /dev/null @@ -1,16 +0,0 @@ -using PostIt.ViewModels; - -namespace PostIt.ViewModels.Comands; - -public class MBrushViewModel : BillingCommandPageViewModel -{ - public MBrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) - : base(activity, performer, form, billingClient) - { - } - - protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) - { - throw new System.NotImplementedException(); - } -} 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 index 5662d495..c2ff3294 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs @@ -12,14 +12,16 @@ 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; } + public partial double? Latitude { get; set; } [ObservableProperty] - public partial double Longitude { get; set; } + public partial double? Longitude { get; set; } [ObservableProperty] @@ -48,12 +50,10 @@ public partial class RdvViewModel : BillingCommandPageViewModel if (existingQuery.Location is not null) { Address = existingQuery.Location.Address ?? string.Empty; - Latitude = existingQuery.Location.Latitude ?? 0; - Longitude = existingQuery.Location.Longitude ?? 0; + Latitude = existingQuery.Location.Latitude; + Longitude = existingQuery.Location.Longitude; } - - StatusMessage = $"Commande #{existingQuery.Id} chargée."; } @@ -135,7 +135,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel } - if (IsRdv && string.IsNullOrWhiteSpace(Reason)) + if (string.IsNullOrWhiteSpace(Reason)) { StatusMessage = "Le motif du rendez-vous est requis."; return; @@ -168,27 +168,23 @@ public partial class RdvViewModel : BillingCommandPageViewModel } }; - if (IsRdv) + if (IsEditingExisting) { - 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); - } + 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}." @@ -207,4 +203,9 @@ public partial class RdvViewModel : BillingCommandPageViewModel 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 57ab091c..30783764 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace PostIt.ViewModels; diff --git a/src/Yavsc.Server/Models/Messaging/EstimationEvent.cs b/src/Yavsc.Server/Models/Messaging/EstimationEvent.cs index 32797d4f..aa2e8946 100644 --- a/src/Yavsc.Server/Models/Messaging/EstimationEvent.cs +++ b/src/Yavsc.Server/Models/Messaging/EstimationEvent.cs @@ -13,15 +13,15 @@ namespace Yavsc.Models.Messaging { Topic = "Estimation"; Estimation = estimate; - perfer = estimate.Owner; + performer = estimate.Owner; ProviderInfo = new ProviderClientInfo { - Rate = perfer.Rate, - UserName = perfer.Performer.UserName, - Avatar = perfer.Performer.Avatar, - UserId = perfer.PerformerId + Rate = performer.Rate, + UserName = performer.Performer.UserName, + Avatar = performer.Performer.Avatar, + UserId = performer.PerformerId }; - Sender = perfer.Performer.UserName; + Sender = performer.Performer.UserName; _localizer = SR; } @@ -31,7 +31,7 @@ namespace Yavsc.Models.Messaging ProviderClientInfo ProviderInfo { get; set; } Estimate Estimation { get; set; } - private readonly PerformerProfile perfer; + private readonly PerformerProfile performer; public string Topic { @@ -47,7 +47,7 @@ namespace Yavsc.Models.Messaging public string CreateBody() { - return string.Format(_localizer["EstimationMessageToClient"], perfer.Performer.UserName, this.Estimation.Bill.Addition()); + return string.Format(_localizer["EstimationMessageToClient"], performer.Performer.UserName, this.Estimation.Bill.Addition()); } } }