Estimate lists
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m1s
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m1s
This commit is contained in:
parent
48ffe835df
commit
110ed279f5
12 changed files with 556 additions and 75 deletions
134
src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
Normal file
134
src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<BillingQueryDetailsPage>();
|
||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||
services.AddTransient<EstimateEditionPage>();
|
||||
services.AddTransient<EstimateListPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
Normal file
71
src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
Normal 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>
|
||||
17
src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
Normal file
17
src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue