Estimate managment from PostIt #53

Merged
notazof merged 20 commits from feat/sign-estimate into main 2026-09-19 18:39:26 +01:00
12 changed files with 556 additions and 75 deletions
Showing only changes of commit 110ed279f5 - Show all commits

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

Paul Schneider 2026-09-15 00:19:23 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

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>

View file

@ -55,6 +55,22 @@ public sealed class EstimateApiClient
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
/// <summary>
/// Lists the ongoing estimates (not yet validated by the client) where
/// the current user is the client who initiated the request
/// (<c>GET estimate/asclient</c>).
/// </summary>
public Task<List<EstimateDto>> GetOngoingEstimatesAsClientAsync(CancellationToken ct = default)
=> _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/asclient"), ct: ct);
/// <summary>
/// Lists the ongoing estimates (not yet validated by the client)
/// established by the current user as the provider
/// (<c>GET estimate/asprovider</c>).
/// </summary>
public Task<List<EstimateDto>> GetOngoingEstimatesAsProviderAsync(CancellationToken ct = default)
=> _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/asprovider"), ct: ct);
/// <summary>
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
/// the server also stamps the linked command as validated.

View file

@ -206,4 +206,110 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
/// <summary>
/// Seeds an estimate owned by alice (the provider) for the given
/// client, optionally already validated by the client.
/// </summary>
private long SeedEstimate(string clientId, bool clientValidated)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = clientId,
OwnerId = "alice",
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
};
db.Estimates.Add(estimate);
db.SaveChanges();
return estimate.Id;
}
[Fact]
public async Task GetOngoingEstimatesAsClient_returns_only_the_current_users_ongoing_estimates()
{
_fixture.ResetAndSeedActivityGraph();
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
SeedEstimate(clientId: "alice", clientValidated: false); // another client's: excluded
using var http = NewClient("bob");
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
Assert.Equal("bob", item.GetProperty("clientId").GetString());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
{
_fixture.ResetAndSeedActivityGraph();
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
using var http = NewClient("alice");
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
Assert.Single(item.GetProperty("bill").EnumerateArray());
}
[Fact]
public async Task GetOngoingEstimatesAsClient_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -198,6 +198,28 @@ namespace Yavsc.Controllers
return Ok(estimate);
}
// GET: api/estimate/asclient
// Ongoing estimates (not yet validated by the client) where the
// current user is the client who initiated the request.
[HttpGet("asclient")]
public IActionResult GetOngoingEstimatesAsClient()
{
var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill)
.Where(e => e.ClientId == uid && e.ClientValidationDate == default));
}
// GET: api/estimate/asprovider
// Ongoing estimates (not yet validated by the client) established
// by the current user as the provider.
[HttpGet("asprovider")]
public IActionResult GetOngoingEstimatesAsProvider()
{
var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill)
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
}
protected override void Dispose(bool disposing)
{
if (disposing)

View file

@ -6,74 +6,6 @@
bool multipleact = Model.Count()>1;
}
@section scripts {
<script>
$(document).ready(function(){
var car = $('.carousel').first();
var fi = car.children('.carousel-inner').first()
.children('.item').first();
var capt = fi.children('.carousel-caption-s');
fi.addClass('active');
car.animate({'background-color':'rgba(0, 0, 0, .5)'},500,function() {
car.animate({'background-color':'rgba(0, 0, 0, 0)'},500)
})
})
</script>
}
@section subbanner {
<div id="myCarousel" class="carousel slide" data-ride="carousel" data-interval="6000">
@if (multipleact) {
<ol class="carousel-indicators">
@{
foreach (var act in Model) {
if (i==0) {
<li data-target="#myCarousel" data-slide-to="0" class="active"></li>
} else {
<li data-target="#myCarousel" data-slide-to="@i"></li>
}
i++;
}
}
</ol> }
<div class="carousel-inner" role="listbox">
@{
string cls = "item";
i=0;
foreach (var act in Model) {
<div class="@cls" style="background-image: url('@act.Photo'); background-repeat: no-repeat; ">
<div class="carousel-caption-s" >
<p><em>@act.Name</em><br/>
@act.Description </p>
@if (act.Children.Count>0) {
<a asp-route-id="@act.Code" class="btn btn-light">
@foreach (Activity c in act.Children) {
@Html.DisplayFor(subact=>c)
}
</a>
}
@foreach (var frm in act.Forms) {
<a class="btn btn-primary" asp-controller="FrontOffice" asp-action="@frm.ActionName" asp-route-id="@act.Code">
@frm.Title
</a>
}
</div>
</div>
i++;
cls = "item";
}
}
</div>
</div>
}
@section ctxmenu {
@if (ViewBag.Announces!=null) {
@ -81,10 +13,4 @@
<img src="@SiteSettings.Value.Logo" alt="announces" title="Annonces @SiteSettings.Value.Title" /></a></li>
}
}
@section header {
<style>
.navbar {
margin-bottom: 0;
}
</style>
}