From eb22e4a5ab2ba63f11055059ca96abad3b442d59 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 15 Sep 2026 00:19:23 +0100 Subject: [PATCH] Estimate lists --- .../EstimateListPageViewModelTests.cs | 134 +++++++++++++++ .../Helpers/ServiceCollectionHelpers.cs | 1 + src/PostIt/PostIt/ViewLocator.cs | 1 + .../Activity/EstimateListPageViewModel.cs | 156 ++++++++++++++++++ .../ViewModels/Layout/HomePageViewModel.cs | 23 +++ .../Views/Activity/EstimateListPage.axaml | 71 ++++++++ .../Views/Activity/EstimateListPage.axaml.cs | 17 ++ src/PostIt/PostIt/Views/Layout/HomePage.axaml | 8 + src/Yavsc.Api.Client/EstimateApiClient.cs | 16 ++ .../EstimateApiControllerTests.cs | 106 ++++++++++++ .../Business/EstimateApiController.cs | 22 +++ src/Yavsc.Org/Views/Home/Index.cshtml | 76 +-------- 12 files changed, 556 insertions(+), 75 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs create mode 100644 src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml create mode 100644 src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs new file mode 100644 index 000000000..6158b4132 --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class EstimateListPageViewModelTests +{ + [Fact] + public async Task Client_perspective_calls_asclient_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); + Assert.Equal("Mes devis à valider", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + // Most recent first + Assert.Equal(2, vm.Estimates[0].Id); + Assert.Equal(1, vm.Estimates[1].Id); + } + + [Fact] + public async Task Provider_perspective_calls_asprovider_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); + Assert.Equal("Mes devis en attente", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task FilterText_filters_by_title_and_counterpart() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + await vm.InitializeAsync(); + + vm.FilterText = "plomberie"; + Assert.Single(vm.Estimates); + Assert.Equal(1, vm.Estimates[0].Id); + + vm.FilterText = "BOB"; + Assert.Single(vm.Estimates); + Assert.Equal("bob", vm.Estimates[0].ClientId); + + vm.FilterText = string.Empty; + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task Empty_result_sets_an_info_status() + { + var api = new StubEstimateListApi { Empty = true }; + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + await vm.InitializeAsync(); + + Assert.Empty(vm.Estimates); + Assert.Contains("Aucun devis", vm.StatusMessage); + } + + private sealed class StubEstimateListApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + public bool Empty { get; init; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var items = Empty + ? new List() + : new List + { + new() + { + Id = 1, + Title = "Devis plomberie", + Description = "Réparation fuite", + ClientId = "bob", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List + { + new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m }, + }, + }, + new() + { + Id = 2, + Title = "Devis électricité", + Description = "Mise aux normes", + ClientId = "carol", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List(), + }, + }; + return Task.FromResult((T)(object)items); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 6bd54ca20..bc5e7298f 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -62,6 +62,7 @@ public static class ServiceCollectionHelpers 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 6f8c5d902..11797b714 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -54,6 +54,7 @@ public class ViewLocator : IDataTemplate BillingQueryDetailsPageViewModel => services.GetRequiredService(), ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), EstimateEditionPageViewModel => services.GetRequiredService(), + EstimateListPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs new file mode 100644 index 000000000..e5c4d355f --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// Point de vue d'une liste de devis en cours (non encore validés par +/// le client). +/// +public enum EstimateListPerspective +{ + /// « Mes devis à valider » — l'utilisateur courant est le client. + Client, + + /// « Mes devis en attente » — l'utilisateur courant est le fournisseur. + Provider, +} + +/// +/// Liste des devis en cours servie par GET api/v1/estimate/asclient +/// ou GET api/v1/estimate/asprovider selon la perspective choisie. +/// +public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly EstimateApiClient _estimateClient; + private readonly EstimateListPerspective _perspective; + private List _allEstimates = new(); + + public EstimateListPerspective Perspective => _perspective; + + public string Title => _perspective == EstimateListPerspective.Client + ? "Mes devis à valider" + : "Mes devis en attente"; + + public string Subtitle => _perspective == EstimateListPerspective.Client + ? "Point de vue client" + : "Point de vue fournisseur"; + + /// + /// Libellé de la colonne « autre partie » : côté client on montre + /// le fournisseur (OwnerId), côté fournisseur on montre le client. + /// + public string CounterpartLabel => _perspective == EstimateListPerspective.Client + ? "Fournisseur" + : "Client"; + + [ObservableProperty] + public partial ObservableCollection Estimates { get; set; } = new(); + + [ObservableProperty] + public partial string FilterText { get; set; } = string.Empty; + + [ObservableProperty] + public partial EstimateDto? SelectedEstimate { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Chargement des devis..."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des devis..."); + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) + { + _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); + _perspective = perspective; + } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var items = (_perspective == EstimateListPerspective.Client + ? await _estimateClient.GetOngoingEstimatesAsClientAsync().ConfigureAwait(true) + : await _estimateClient.GetOngoingEstimatesAsProviderAsync().ConfigureAwait(true)) + ?? new List(); + + _allEstimates = items + .OrderByDescending(x => x.Id) + .ToList(); + + ApplyFilter(); + this.SetInfoStatus(_allEstimates.Count == 0 + ? "Aucun devis en cours." + : $"{_allEstimates.Count} devis en cours chargé(s)."); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + _allEstimates = new List(); + Estimates = new ObservableCollection(); + this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous."); + } + catch (Exception ex) + { + _allEstimates = new List(); + Estimates = new ObservableCollection(); + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + partial void OnFilterTextChanged(string value) + { + ApplyFilter(); + } + + private void ApplyFilter() + { + var query = FilterText?.Trim(); + var filtered = string.IsNullOrWhiteSpace(query) + ? _allEstimates + : _allEstimates.Where(x => + ContainsInsensitive(x.Title, query) + || ContainsInsensitive(x.Description, query) + || ContainsInsensitive(x.CommandType, query) + || ContainsInsensitive(x.ClientId, query) + || ContainsInsensitive(x.OwnerId, query)) + .ToList(); + + Estimates = new ObservableCollection(filtered); + } + + private static bool ContainsInsensitive(string? value, string query) + => !string.IsNullOrWhiteSpace(value) + && value.Contains(query, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index fd2ff1091..ea92f1d0a 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -32,11 +32,15 @@ public class HomePageViewModel : ViewModelBase OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); + OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); + OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); } public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } + public IAsyncRelayCommand OpenClientEstimates { get; } + public IAsyncRelayCommand OpenProviderEstimates { get; } private async Task OpenActivitiesAsync() { @@ -72,6 +76,25 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenEstimateListAsync(EstimateListPerspective perspective) + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var estimateClient = app.ServiceProvider?.GetRequiredService(); + if (estimateClient is null) + { + throw new InvalidOperationException("Client devis indisponible."); + } + + var vm = new EstimateListPageViewModel(estimateClient, perspective); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml new file mode 100644 index 000000000..d5055dd1f --- /dev/null +++ b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml @@ -0,0 +1,71 @@ + + + + + + + + +