From 2397f4d3a8f9214269c3939da58b3a18df584178 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 12 Sep 2026 13:29:49 +0100 Subject: [PATCH] Add provider ongoing requests flow and sort persistence --- ...oviderOngoingRequestsPageViewModelTests.cs | 208 ++++++++++ .../Helpers/ServiceCollectionHelpers.cs | 1 + src/PostIt/PostIt/ViewLocator.cs | 1 + .../ProviderOngoingRequestsPageViewModel.cs | 356 ++++++++++++++++++ .../ViewModels/Layout/HomePageViewModel.cs | 20 +- .../PostIt/ViewModels/Settings/Settings.cs | 8 +- .../ProviderOngoingRequestsPage.axaml | 98 +++++ .../ProviderOngoingRequestsPage.axaml.cs | 17 + src/Yavsc.Api.Client/BillingApiClient.cs | 8 + .../Controllers/Business/BillingController.cs | 69 ++++ 10 files changed, 784 insertions(+), 2 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs create mode 100644 src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml create mode 100644 src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs diff --git a/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs new file mode 100644 index 000000000..945072870 --- /dev/null +++ b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs @@ -0,0 +1,208 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class ProviderOngoingRequestsPageViewModelTests +{ + [Fact] + public async Task RefreshAsync_calls_provider_endpoint_and_filters_out_unknown_billing_codes() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/bill/provider/ongoing", api.Paths); + Assert.Equal(3, vm.Queries.Count); + Assert.Equal(12, vm.Queries[0].Id); + Assert.Equal(11, vm.Queries[1].Id); + Assert.Equal(10, vm.Queries[2].Id); + } + + [Fact] + public async Task FilterText_filters_by_activity_code_and_status() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + + vm.FilterText = "mbrush"; + Assert.Single(vm.Queries); + Assert.Equal("MBrush", vm.Queries[0].BillingCode); + + vm.FilterText = "accepted"; + Assert.Single(vm.Queries); + Assert.Equal(QueryStatus.Accepted, vm.Queries[0].Status); + } + + [Fact] + public async Task OpenSelectedEditorCommand_can_execute_only_when_selection_exists() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + + Assert.False(vm.OpenSelectedEditorCommand.CanExecute(null)); + + vm.SelectedQuery = vm.Queries[0]; + + Assert.True(vm.OpenSelectedEditorCommand.CanExecute(null)); + } + + [Fact] + public async Task SelectedSortOption_date_keeps_most_recent_first() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDate; + + Assert.Equal(new long[] { 12, 11, 10 }, vm.Queries.Select(q => q.Id).ToArray()); + } + + [Fact] + public async Task SelectedSortOption_date_ascending_keeps_oldest_first() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDateAsc; + + Assert.Equal(new long[] { 10, 11, 12 }, vm.Queries.Select(q => q.Id).ToArray()); + } + + [Fact] + public async Task SelectedSortOption_status_prioritizes_inprogress_then_accepted_then_inserted() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ProviderOngoingRequestsPageViewModel(client); + + await vm.InitializeAsync(); + vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus; + + Assert.Equal(new long[] { 11, 12, 10 }, vm.Queries.Select(q => q.Id).ToArray()); + } + + [Fact] + public void Constructor_reads_saved_sort_option_from_settings() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var settings = new Settings + { + ProviderOngoingRequestsSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus, + }; + + var vm = new ProviderOngoingRequestsPageViewModel(client, settings); + + Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByStatus, vm.SelectedSortOption); + } + + [Fact] + public void Constructor_falls_back_to_default_when_saved_sort_is_invalid() + { + var api = new StubProviderApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var settings = new Settings + { + ProviderOngoingRequestsSortOption = "invalide", + }; + + var vm = new ProviderOngoingRequestsPageViewModel(client, settings); + + Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption); + } + + private sealed class StubProviderApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var data = new List + { + new() + { + Id = 10, + BillingCode = "Rdv", + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Inserted, + Description = "Rendez-vous", + EventDate = new DateTime(2026, 9, 10, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 11, + BillingCode = "MBrush", + ActivityCode = "hair", + PerformerId = "perf-1", + ClientId = "cli-2", + Status = QueryStatus.InProgress, + Description = "Coupe multiple", + EventDate = new DateTime(2026, 9, 11, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 12, + BillingCode = "Brush", + ActivityCode = "hair", + PerformerId = "perf-1", + ClientId = "cli-3", + Status = QueryStatus.Accepted, + Description = "Coupe simple", + EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 13, + BillingCode = "", + ActivityCode = "unknown", + PerformerId = "perf-1", + ClientId = "cli-4", + Status = QueryStatus.Accepted, + Description = "Doit être filtrée", + EventDate = new DateTime(2026, 9, 13, 10, 0, 0, DateTimeKind.Utc), + } + }; + + return Task.FromResult((T)(object)data); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index c87eb9286..0ebd2af19 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -59,6 +59,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 97538abbb..72bd2559e 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -52,6 +52,7 @@ public class ViewLocator : IDataTemplate PostAclDialogViewModel => services.GetRequiredService(), BillingQueriesPageViewModel => services.GetRequiredService(), BillingQueryDetailsPageViewModel => services.GetRequiredService(), + ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs new file mode 100644 index 000000000..562f24988 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActionStatusViewModel +{ + public const string SortByDate = "Date (plus récent d'abord)"; + public const string SortByDateAsc = "Date (plus ancien d'abord)"; + public const string SortByStatus = "Statut (en cours d'abord)"; + + private readonly BillingApiClient _billingClient; + private readonly Settings? _settings; + private List _allQueries = new(); + + [ObservableProperty] + public partial ObservableCollection Queries { get; set; } = new(); + + [ObservableProperty] + public partial string FilterText { get; set; } = string.Empty; + + public IReadOnlyList SortOptions { get; } = new[] + { + SortByDate, + SortByDateAsc, + SortByStatus, + }; + + [ObservableProperty] + public partial string SelectedSortOption { get; set; } = SortByDate; + + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))] + public partial BillingQuerySummaryDto? SelectedQuery { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Chargement des demandes fournisseur..."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des demandes fournisseur..."); + + public string Title => "Mes demandes en cours"; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public ProviderOngoingRequestsPageViewModel(BillingApiClient billingClient, Settings? settings = null) + { + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + _settings = settings; + + if (_settings is not null) + { + var preferredSort = NormalizeSortOption(_settings.ProviderOngoingRequestsSortOption); + if (!string.Equals(preferredSort, SelectedSortOption, StringComparison.Ordinal)) + { + SelectedSortOption = preferredSort; + } + } + } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var items = await _billingClient.GetProviderOngoingQueriesAsync().ConfigureAwait(true) ?? new(); + _allQueries = items + .Where(x => !string.IsNullOrWhiteSpace(x.BillingCode)) + .OrderByDescending(x => x.EventDate ?? DateTime.MinValue) + .ThenByDescending(x => x.Id) + .ToList(); + + ApplyFilter(); + this.SetInfoStatus(_allQueries.Count == 0 + ? "Aucune demande en cours pour votre profil fournisseur." + : $"{_allQueries.Count} demande(s) en cours chargée(s)."); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + _allQueries = new List(); + Queries = new ObservableCollection(); + this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."); + } + catch (Exception ex) + { + _allQueries = new List(); + Queries = new ObservableCollection(); + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + private bool CanOpenSelectedQuery() => SelectedQuery is not null; + + private bool CanOpenSelectedEditor() => SelectedQuery is not null; + + [RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))] + public async Task OpenSelectedQueryAsync() + { + if (SelectedQuery is null) + { + this.SetWarningStatus("Sélectionnez une demande."); + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + IsBusy = true; + try + { + var details = await _billingClient + .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) + .ConfigureAwait(true); + var (activity, performer, form) = BuildNavigationContext(SelectedQuery); + + var vm = new BillingQueryDetailsPageViewModel( + activity, + performer, + form, + _billingClient, + details, + isReadOnly: false); + + await app.PushPageAsync(vm).ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur lors de l'ouverture: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanOpenSelectedEditor))] + public async Task OpenSelectedEditorAsync() + { + if (SelectedQuery is null) + { + this.SetWarningStatus("Sélectionnez une demande."); + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + IsBusy = true; + try + { + var details = await _billingClient + .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) + .ConfigureAwait(true); + + var (activity, performer, form) = BuildNavigationContext(SelectedQuery); + var vm = form.CreateCommandPageViewModel(activity, performer, _billingClient); + if (vm is null) + { + this.SetWarningStatus($"Le formulaire '{form.ActionName}' n'est pas pris en charge en édition."); + return; + } + + await vm.InitializeAsync(details).ConfigureAwait(true); + await app.PushPageAsync(vm).ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur lors de l'ouverture en édition: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + partial void OnFilterTextChanged(string value) + { + ApplyFilter(); + } + + partial void OnSelectedSortOptionChanged(string value) + { + var normalized = NormalizeSortOption(value); + if (!string.Equals(normalized, value, StringComparison.Ordinal)) + { + SelectedSortOption = normalized; + return; + } + + PersistSortPreference(value); + ApplyFilter(); + } + + private void ApplyFilter() + { + var query = FilterText?.Trim(); + var filtered = string.IsNullOrWhiteSpace(query) + ? _allQueries + : _allQueries.Where(x => + ContainsInsensitive(x.Description, query) + || ContainsInsensitive(x.ActivityCode, query) + || ContainsInsensitive(x.BillingCode, query) + || ContainsInsensitive(x.ClientId, query) + || ContainsInsensitive(x.Status.ToString(), query)) + .ToList(); + + var sorted = ApplySort(filtered); + Queries = new ObservableCollection(sorted); + } + + private List ApplySort(IEnumerable source) + { + if (string.Equals(SelectedSortOption, SortByStatus, StringComparison.Ordinal)) + { + return source + .OrderBy(x => GetStatusRank(x.Status)) + .ThenByDescending(x => x.EventDate ?? DateTime.MinValue) + .ThenByDescending(x => x.Id) + .ToList(); + } + + if (string.Equals(SelectedSortOption, SortByDateAsc, StringComparison.Ordinal)) + { + return source + .OrderBy(x => x.EventDate ?? DateTime.MinValue) + .ThenBy(x => x.Id) + .ToList(); + } + + return source + .OrderByDescending(x => x.EventDate ?? DateTime.MinValue) + .ThenByDescending(x => x.Id) + .ToList(); + } + + private void PersistSortPreference(string selectedSort) + { + if (_settings is null) + { + return; + } + + if (string.Equals(_settings.ProviderOngoingRequestsSortOption, selectedSort, StringComparison.Ordinal)) + { + return; + } + + _settings.ProviderOngoingRequestsSortOption = selectedSort; + + try + { + _settings.Save(); + } + catch + { + this.SetWarningStatus("Le tri a été appliqué, mais sa sauvegarde a échoué."); + } + } + + private static string NormalizeSortOption(string? sortOption) + { + if (string.Equals(sortOption, SortByDate, StringComparison.Ordinal) + || string.Equals(sortOption, SortByDateAsc, StringComparison.Ordinal) + || string.Equals(sortOption, SortByStatus, StringComparison.Ordinal)) + { + return sortOption!; + } + + return SortByDate; + } + + private static int GetStatusRank(QueryStatus status) + => status switch + { + QueryStatus.InProgress => 0, + QueryStatus.Accepted => 1, + QueryStatus.Inserted => 2, + QueryStatus.Success => 3, + QueryStatus.Rejected => 4, + QueryStatus.Failed => 5, + _ => 99, + }; + + private static bool ContainsInsensitive(string? source, string query) + => !string.IsNullOrWhiteSpace(source) + && source.Contains(query, StringComparison.OrdinalIgnoreCase); + + private static (ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form) + BuildNavigationContext(BillingQuerySummaryDto query) + { + var activity = new ActivityInfo + { + Code = query.ActivityCode, + Name = string.IsNullOrWhiteSpace(query.ActivityCode) + ? "Activité" + : query.ActivityCode, + }; + + var performer = new ActivityUserDisplayItem + { + PerformerId = query.PerformerId, + UserName = "Mon profil fournisseur", + AvatarFallbackLabel = "M", + IsPerformerActive = true, + PerformerStatusBadgeLabel = "Actif", + PerformerStatusBadgeBackground = "#E6F7EC", + PerformerStatusBadgeBorder = "#2E7D32", + PerformerStatusBadgeForeground = "#1B5E20", + }; + + var form = new CommandFormSummary + { + ActionName = query.BillingCode, + Title = query.BillingCode, + }; + + return (activity, performer, form); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index a35c8016d..673177863 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using PostIt.Services; +using Yavsc.Api.Client; namespace PostIt.ViewModels; public class HomePageViewModel : ViewModelBase @@ -50,7 +51,24 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } - private Task OpenProviderRequestsAsync() => OpenActivitiesAsync(); + private async Task OpenProviderRequestsAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var billingClient = app.ServiceProvider?.GetRequiredService(); + if (billingClient is null) + { + throw new InvalidOperationException("Client billing indisponible."); + } + + var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } /// /// Avalonia designer constructor. Builds a self-contained VM diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 0864c1416..23e9955f4 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -32,6 +32,9 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; + [ObservableProperty] + public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty; + [ObservableProperty] [JsonIgnore] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); @@ -62,6 +65,7 @@ public partial class Settings : ViewModelBase partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnApiUrlChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty(); + partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -346,6 +350,7 @@ public partial class Settings : ViewModelBase ? settings.ApiUrl : this.ApiUrl; this.SearchText = settings.SearchText ?? string.Empty; + this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -424,6 +429,7 @@ public partial class Settings : ViewModelBase this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; this.ApiUrl = "https://api.pschneider.fr/api/v1/"; this.SearchText = string.Empty; + this.ProviderOngoingRequestsSortOption = string.Empty; } /// @@ -466,7 +472,7 @@ public partial class Settings : ViewModelBase UnixFileMode.UserRead | UnixFileMode.UserWrite); IsDirty = false; SetActionStatus("Parametres sauvegardes.", StatusSeverity.Info); - + Console.WriteLine($"💾 Settings saved to {configPath}"); } catch (Exception ex) diff --git a/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml new file mode 100644 index 000000000..010bab7b5 --- /dev/null +++ b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml @@ -0,0 +1,98 @@ + + + + + + + + +