diff --git a/ROADMAP.md b/ROADMAP.md index 4c00e629..364b98bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons : > > Chaque jalon a un **critère de sortie** vérifiable. -### Jalon 0 — Fondations techniques *(en cours)* +### Jalon 0 — Fondations techniques > Cible : pouvoir parler du domaine sans se battre avec le runtime. @@ -81,7 +81,7 @@ Trois principes non négociables traversent tous les jalons : --- -### Jalon 1 — Prestation signée de bout en bout +### Jalon 1 — Prestation signée de bout en bout *(en cours)* > Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications. diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index da166fa6..5f467ec8 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -12,12 +12,15 @@ public class ActivitiesPageViewModelTests { var api = new StubActivityApi(); var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken); await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken); + await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, TestContext.Current.CancellationToken); Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]); Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]); + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]); } [Fact] @@ -25,7 +28,8 @@ public class ActivitiesPageViewModelTests { var api = new StubActivityApi(); var client = new ActivityApiClient(api, "https://business.example/api/v1/"); - var vm = new ActivitiesPageViewModel(client); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ActivitiesPageViewModel(client, billingClient); await vm.RefreshAsync(); @@ -76,6 +80,10 @@ public class ActivitiesPageViewModelTests Name = "Brush", Description = "Coiffure à domicile", PerformerCount = 1, + Forms = new List + { + new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" } + }, Children = new List { new() @@ -85,6 +93,10 @@ public class ActivitiesPageViewModelTests Description = "Spécialisation premium", ParentCode = "brush", PerformerCount = 1, + Forms = new List + { + new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" } + } } } } diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs new file mode 100644 index 00000000..13b08f69 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -0,0 +1,82 @@ +using System.Net.Http; +using System.Text.Json; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class BillingCommandPageViewModelTests +{ + [Fact] + public async Task SubmitAsync_posts_rdv_payload_to_selected_billing_route() + { + 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" }, + 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, + }; + + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.LastPath); + Assert.NotNull(api.LastBody); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal("dev", json.RootElement.GetProperty("ActivityCode").GetString()); + Assert.Equal("perf-1", json.RootElement.GetProperty("PerformerId").GetString()); + Assert.Equal("Point de cadrage", json.RootElement.GetProperty("Reason").GetString()); + Assert.Equal((int)QueryStatus.Inserted, json.RootElement.GetProperty("Status").GetInt32()); + } + + [Fact] + public async Task SubmitAsync_refuses_unsupported_billing_code() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, + client); + + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Null(api.LastPath); + Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastPath = path; + LastBody = body; + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 98b6efb3..bdc3cee6 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -24,6 +24,7 @@ public static class ServiceCollectionHelpers var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); + var billingClient = new BillingApiClient(api, settings.BusinessApiUrl); var userDirectory = new UserDirectory(userSearchClient); // Vues @@ -47,6 +48,8 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); @@ -55,6 +58,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); services.AddSingleton(activityClient); + services.AddSingleton(billingClient); services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index a81bf6ce..85a7eb55 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -40,6 +40,8 @@ public class ViewLocator : IDataTemplate Settings => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), ActivitiesPageViewModel => services.GetRequiredService(), + CommandFormsPageViewModel => services.GetRequiredService(), + BillingCommandPageViewModel => 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 e39a2c18..5268d07a 100644 --- a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs @@ -4,8 +4,10 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; @@ -14,6 +16,7 @@ namespace PostIt.ViewModels; public partial class ActivitiesPageViewModel : ViewModelBase { private readonly ActivityApiClient _client; + private readonly BillingApiClient _billingClient; private bool _syncingSelection; [ObservableProperty] @@ -31,6 +34,9 @@ public partial class ActivitiesPageViewModel : ViewModelBase [ObservableProperty] public partial ObservableCollection Performers { get; set; } = new(); + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenCommandFormsCommand))] + public partial ActivityUserDisplayItem? SelectedPerformer { get; set; } + [ObservableProperty] public partial bool IsBusy { get; set; } @@ -40,6 +46,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity; public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)"; public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)"; + public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0; public override bool CanNavigateNext { @@ -53,9 +60,10 @@ public partial class ActivitiesPageViewModel : ViewModelBase protected set { _ = value; } } - public ActivitiesPageViewModel(ActivityApiClient client) + public ActivitiesPageViewModel(ActivityApiClient client, BillingApiClient billingClient) { _client = client ?? throw new ArgumentNullException(nameof(client)); + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); } partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value) @@ -154,11 +162,13 @@ public partial class ActivitiesPageViewModel : ViewModelBase OnPropertyChanged(nameof(CurrentActivity)); OnPropertyChanged(nameof(SelectedActivityLabel)); OnPropertyChanged(nameof(CurrentActivityLabel)); + OnPropertyChanged(nameof(CurrentFormCount)); Specializations = new ObservableCollection(activity?.Children ?? new()); if (activity is null) { Performers = new ObservableCollection(); + SelectedPerformer = null; return; } @@ -179,6 +189,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase OnPropertyChanged(nameof(CurrentActivity)); OnPropertyChanged(nameof(CurrentActivityLabel)); + OnPropertyChanged(nameof(CurrentFormCount)); if (specialization is null) { @@ -200,21 +211,47 @@ public partial class ActivitiesPageViewModel : ViewModelBase var list = await _client.GetUsersAsync(activity.Code); Performers = new ObservableCollection((list ?? new()) .Select(ActivityUserDisplayItem.FromDto)); + SelectedPerformer = null; StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)"; } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { Performers = new ObservableCollection(); + SelectedPerformer = null; StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; } catch (Exception ex) { Performers = new ObservableCollection(); + SelectedPerformer = null; StatusMessage = $"Erreur: {ex.Message}"; } finally { IsBusy = false; + OpenCommandFormsCommand.NotifyCanExecuteChanged(); } } + + private bool CanOpenCommandForms() + => SelectedPerformer is not null && CurrentActivity?.Forms?.Count > 0; + + [RelayCommand(CanExecute = nameof(CanOpenCommandForms))] + private async Task OpenCommandFormsAsync() + { + if (SelectedPerformer is null || CurrentActivity is null) + { + StatusMessage = "Sélectionnez un utilisateur et une activité avec formulaire."; + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = new CommandFormsPageViewModel(CurrentActivity, SelectedPerformer, _billingClient); + await app.PushPageAsync(vm); + } } diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs new file mode 100644 index 00000000..ba66075f --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs @@ -0,0 +1,183 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +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; +using Yavsc.Models.Relationship; + +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; + + public string Title => Form.Title; + public string PerformerLabel => Performer.UserName; + public string ActivityLabel => Activity.Name; + public bool IsSupported => string.Equals(Form.ActionName, BillingCodes.Rdv, StringComparison.Ordinal); + public string BillingRoute => $"/billing/{Form.ActionName}"; + public string SupportMessage => IsSupported + ? "Complétez les informations du rendez-vous 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; + } + + [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 (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 (!TryParseCoordinate(LatitudeText, out var latitude)) + { + StatusMessage = "Latitude invalide."; + return; + } + + if (!TryParseCoordinate(LongitudeText, out var longitude)) + { + StatusMessage = "Longitude invalide."; + return; + } + + IsBusy = true; + try + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = eventDate, + Location = new Location + { + Address = Address.Trim(), + Latitude = latitude, + Longitude = longitude, + }, + Reason = Reason.Trim(), + Status = QueryStatus.Inserted, + }).ConfigureAwait(true); + + StatusMessage = $"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; + } + } + + 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); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs new file mode 100644 index 00000000..be60e936 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class CommandFormsPageViewModel : ViewModelBase +{ + private readonly BillingApiClient _billingClient; + + public ActivityBrowseItemDto Activity { get; } + public ActivityUserDisplayItem Performer { get; } + + [ObservableProperty] + public partial ObservableCollection Forms { get; set; } + + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand))] + public partial CommandFormSummaryDto? SelectedForm { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } + + public string Title => $"Formulaires pour {Performer.UserName}"; + public string ContextLabel => $"{Activity.Name} · {Forms.Count} formulaire(s)"; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public CommandFormsPageViewModel( + ActivityBrowseItemDto activity, + ActivityUserDisplayItem performer, + BillingApiClient billingClient) + { + Activity = activity ?? throw new ArgumentNullException(nameof(activity)); + Performer = performer ?? throw new ArgumentNullException(nameof(performer)); + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + + Forms = new ObservableCollection((activity.Forms ?? new()) + .OrderBy(f => f.Title) + .ThenBy(f => f.ActionName)); + SelectedForm = Forms.FirstOrDefault(); + StatusMessage = Forms.Count == 0 + ? "Aucun formulaire n'est disponible pour cette activité." + : "Choisissez le formulaire à utiliser."; + } + + private bool CanOpenSelectedForm() => SelectedForm is not null; + + [RelayCommand(CanExecute = nameof(CanOpenSelectedForm))] + private async Task OpenSelectedFormAsync() + { + if (SelectedForm is null) + { + StatusMessage = "Sélectionnez un formulaire."; + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + await app.PushPageAsync(new BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient)); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml b/src/PostIt/PostIt/Views/ActivitiesPage.axaml index 8dd88c6f..31d7be5f 100644 --- a/src/PostIt/PostIt/Views/ActivitiesPage.axaml +++ b/src/PostIt/PostIt/Views/ActivitiesPage.axaml @@ -65,12 +65,14 @@ - + - + @@ -104,6 +106,10 @@ +