Estimate lists
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m1s

This commit is contained in:
Paul Schneider 2026-09-15 00:19:23 +01:00
commit 110ed279f5
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
12 changed files with 556 additions and 75 deletions

View file

@ -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<string> Paths { get; } = new();
public bool Empty { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<EstimateDto>))
{
var items = Empty
? new List<EstimateDto>()
: new List<EstimateDto>
{
new()
{
Id = 1,
Title = "Devis plomberie",
Description = "Réparation fuite",
ClientId = "bob",
OwnerId = "alice",
CommandType = "Rdv",
Bill = new List<EstimateLineDto>
{
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<EstimateLineDto>(),
},
};
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<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -62,6 +62,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>();
// ViewModels
services.AddSingleton(settings);

View file

@ -54,6 +54,7 @@ public class ViewLocator : IDataTemplate
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -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;
/// <summary>
/// Point de vue d'une liste de devis en cours (non encore validés par
/// le client).
/// </summary>
public enum EstimateListPerspective
{
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
Client,
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
Provider,
}
/// <summary>
/// Liste des devis en cours servie par <c>GET api/v1/estimate/asclient</c>
/// ou <c>GET api/v1/estimate/asprovider</c> selon la perspective choisie.
/// </summary>
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _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";
/// <summary>
/// Libellé de la colonne « autre partie » : côté client on montre
/// le fournisseur (OwnerId), côté fournisseur on montre le client.
/// </summary>
public string CounterpartLabel => _perspective == EstimateListPerspective.Client
? "Fournisseur"
: "Client";
[ObservableProperty]
public partial ObservableCollection<EstimateDto> 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<EstimateDto>();
_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<EstimateDto>();
Estimates = new ObservableCollection<EstimateDto>();
this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
_allEstimates = new List<EstimateDto>();
Estimates = new ObservableCollection<EstimateDto>();
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<EstimateDto>(filtered);
}
private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
}

View file

@ -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<EstimateApiClient>();
if (estimateClient is null)
{
throw new InvalidOperationException("Client devis indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -0,0 +1,71 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateListPage"
x:DataType="vm:EstimateListPageViewModel"
Header="{Binding Title}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
<TextBlock Text="{Binding Subtitle}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBox Grid.Column="2"
PlaceholderText="Filtrer: titre, description, type, partie..."
Text="{Binding FilterText, Mode=TwoWay}" />
</Grid>
<ListBox Grid.Row="2"
ItemsSource="{Binding Estimates}"
SelectedItem="{Binding SelectedEstimate, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dto:EstimateDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Title}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Id}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding Description}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding Bill.Count}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class EstimateListPage : ContentPage
{
public EstimateListPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -26,5 +26,13 @@
Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis à valider"
Command="{Binding OpenClientEstimates}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis en attente"
Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>