Estimate lists
This commit is contained in:
parent
6e92c57c1d
commit
eb22e4a5ab
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<BillingQueryDetailsPage>();
|
||||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||||
services.AddTransient<EstimateEditionPage>();
|
services.AddTransient<EstimateEditionPage>();
|
||||||
|
services.AddTransient<EstimateListPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ public class ViewLocator : IDataTemplate
|
||||||
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
||||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||||
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => 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);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||||
|
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
||||||
|
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||||
}
|
}
|
||||||
public IAsyncRelayCommand OpenBlogs { get; }
|
public IAsyncRelayCommand OpenBlogs { get; }
|
||||||
public IAsyncRelayCommand OpenActivities { get; }
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||||
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -72,6 +76,25 @@ public class HomePageViewModel : ViewModelBase
|
||||||
await app.PushPageAsync(vm);
|
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>
|
/// <summary>
|
||||||
/// Avalonia designer constructor. Builds a self-contained VM
|
/// Avalonia designer constructor. Builds a self-contained VM
|
||||||
/// with a freshly-constructed Settings so the XAML preview can
|
/// 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}"
|
Command="{Binding OpenProviderRequests}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
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>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,22 @@ public sealed class EstimateApiClient
|
||||||
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
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>
|
/// <summary>
|
||||||
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
|
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
|
||||||
/// the server also stamps the linked command as validated.
|
/// the server also stamps the linked command as validated.
|
||||||
|
|
|
||||||
|
|
@ -206,4 +206,110 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,28 @@ namespace Yavsc.Controllers
|
||||||
return Ok(estimate);
|
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)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
|
|
|
||||||
|
|
@ -6,74 +6,6 @@
|
||||||
bool multipleact = Model.Count()>1;
|
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 {
|
@section ctxmenu {
|
||||||
@if (ViewBag.Announces!=null) {
|
@if (ViewBag.Announces!=null) {
|
||||||
|
|
@ -81,10 +13,4 @@
|
||||||
<img src="@SiteSettings.Value.Logo" alt="announces" title="Annonces @SiteSettings.Value.Title" /></a></li>
|
<img src="@SiteSettings.Value.Logo" alt="announces" title="Annonces @SiteSettings.Value.Title" /></a></li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@section header {
|
|
||||||
<style>
|
|
||||||
.navbar {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue