Estimate UI
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m20s

This commit is contained in:
Paul Schneider 2026-09-13 18:54:27 +01:00
commit 848f8be42f
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
19 changed files with 1235 additions and 14 deletions

View file

@ -0,0 +1,270 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateEditionPageViewModelTests
{
private static BillingQuerySummaryDto SampleQuery() => new()
{
Id = 42,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
};
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
}
[Fact]
public void Constructor_prefills_description_and_adds_a_first_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
Assert.Equal("Coupe simple", vm.EstimateDescription);
Assert.Single(vm.Lines);
Assert.Same(vm.Lines[0], vm.SelectedLine);
Assert.Contains("#42", vm.ContextLabel);
Assert.Contains("cli-1", vm.ContextLabel);
}
[Fact]
public void AddLine_appends_and_selects_the_new_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.AddLineCommand.Execute(null);
Assert.Equal(2, vm.Lines.Count);
Assert.Same(vm.Lines[1], vm.SelectedLine);
}
[Fact]
public void RemoveLine_removes_the_selected_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
var first = vm.Lines[0];
vm.RemoveLineCommand.Execute(null);
Assert.Empty(vm.Lines);
Assert.Null(vm.SelectedLine);
Assert.False(vm.RemoveLineCommand.CanExecute(null));
Assert.DoesNotContain(first, vm.Lines);
}
[Fact]
public void Total_sums_line_totals_and_tracks_edits()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Count = 2;
vm.Lines[0].UnitaryCost = 15.5m;
Assert.Equal(31m, vm.Total);
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
vm.AddLineCommand.Execute(null);
vm.Lines[1].Count = 1;
vm.Lines[1].UnitaryCost = 9m;
Assert.Equal(40m, vm.Total);
}
[Fact]
public async Task Send_without_title_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("titre", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_without_any_line_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines.Clear();
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("ligne", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Description = "Oubli du nom";
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("nom", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = " Devis coupe ";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].Count = 2.4m;
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
Assert.Equal("Devis coupe", payload.Title);
Assert.Equal("Coupe simple", payload.Description);
Assert.Empty(payload.AttachedFiles);
Assert.Empty(payload.AttachedGraphics);
var line = Assert.Single(payload.Bill);
Assert.Equal("Coupe", line.Name);
Assert.Equal(2, line.Count);
Assert.Equal(25m, line.UnitaryCost);
Assert.Equal("EUR", line.Currency);
}
[Fact]
public async Task Send_marks_the_page_as_sent_and_disables_resend()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.True(vm.HasSent);
Assert.False(vm.SendCommand.CanExecute(null));
Assert.Equal("Devis envoyé", vm.SendLabel);
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
Assert.Contains("#7", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_surfaces_server_errors_as_error_status()
{
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
await vm.SendCommand.ExecuteAsync(null);
Assert.False(vm.HasSent);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
Assert.True(vm.SendCommand.CanExecute(null));
}
[Fact]
public async Task Send_accepts_negative_amounts_for_discount_lines()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis avec remise";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
vm.AddLineCommand.Execute(null);
vm.Lines[1].Name = "Remise fidélité";
vm.Lines[1].Description = "Remise client régulier";
vm.Lines[1].UnitaryCost = -5m;
Assert.Equal(20m, vm.Total);
await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
}
private sealed class StubEstimateApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public HttpMethod? LastMethod { get; private set; }
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (Failure is not null)
{
throw Failure;
}
if (typeof(T) == typeof(EstimateCreatedDto))
{
var payload = (EstimateDto)body!;
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
return Task.FromResult((T)(object)created);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
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

@ -23,13 +23,11 @@ public class PostItViewModelTests
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id);

View file

@ -126,6 +126,36 @@ public class ProviderOngoingRequestsPageViewModelTests
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
}
[Fact]
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
await vm.InitializeAsync();
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
[Fact]
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
private sealed class StubProviderApi : IYavscApiClient
{
public HttpClient Http { get; } = new();

View file

@ -30,6 +30,7 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -60,6 +61,8 @@ public static class ServiceCollectionHelpers
services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -69,6 +72,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();

View file

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

View file

@ -0,0 +1,293 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Edition d'un devis (<c>Estimate</c>) créé en réponse à une demande
/// client (<see cref="BillingQuerySummaryDto"/>) consultée depuis la
/// page « Mes demandes en cours ». L'envoi poste le devis sur
/// <c>api/v1/estimate</c>; côté serveur, la commande liée
/// (<see cref="BillingQuerySummaryDto.Id"/>) est alors marquée comme
/// validée par le prestataire.
/// </summary>
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id;
public string ClientId => _query.ClientId;
public string BillingCode => _query.BillingCode;
public string Title => $"Devis — demande #{_query.Id}";
public string ContextLabel
=> $"Demande #{_query.Id} · {BillingCode} · client {ClientId}";
public string QueryDescription => string.IsNullOrWhiteSpace(_query.Description)
? "(sans description)"
: _query.Description;
[ObservableProperty]
public partial string EstimateTitle { get; set; } = string.Empty;
[ObservableProperty]
public partial string EstimateDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<EstimateLineItemViewModel> Lines { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))]
public partial EstimateLineItemViewModel? SelectedLine { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool IsBusy { get; set; }
/// <summary>
/// True une fois le devis accepté par le serveur: l'envoi est
/// désactivé pour éviter les doublons, il ne reste que « Retour ».
/// </summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool HasSent { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
public string SendLabel => HasSent ? "Devis envoyé" : "Envoyer le devis";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
{
_query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged;
AddLine();
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
}
[RelayCommand]
private void AddLine()
{
var line = new EstimateLineItemViewModel();
Lines.Add(line);
SelectedLine = line;
}
private bool CanRemoveLine() => SelectedLine is not null && !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanRemoveLine))]
private void RemoveLine()
{
if (SelectedLine is null)
{
return;
}
var index = Lines.IndexOf(SelectedLine);
Lines.Remove(SelectedLine);
SelectedLine = Lines.Count == 0
? null
: Lines[Math.Min(index, Lines.Count - 1)];
}
private bool CanSend() => !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanSend))]
private async Task SendAsync()
{
if (!TryValidate(out var validationMessage))
{
this.SetWarningStatus(validationMessage);
return;
}
IsBusy = true;
try
{
var payload = BuildPayload();
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true;
OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé à l'API devis (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi du devis: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
internal EstimateDto BuildPayload()
{
return new EstimateDto
{
CommandId = QueryId,
ClientId = ClientId,
CommandType = BillingCode,
Title = EstimateTitle.Trim(),
Description = EstimateDescription.Trim(),
Bill = Lines.Select(line => new EstimateLineDto
{
Id = line.Id,
Name = line.Name.Trim(),
Description = line.Description.Trim(),
Count = Math.Max(1, (int)Math.Round(line.Count)),
UnitaryCost = line.UnitaryCost,
Currency = string.IsNullOrWhiteSpace(line.Currency) ? "EUR" : line.Currency.Trim(),
}).ToList(),
};
}
private bool TryValidate(out string message)
{
if (string.IsNullOrWhiteSpace(EstimateTitle))
{
message = "Le titre du devis est requis.";
return false;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
message = "La demande sélectionnée n'identifie pas de client.";
return false;
}
if (string.IsNullOrWhiteSpace(BillingCode))
{
message = "La demande sélectionnée n'a pas de code de facturation.";
return false;
}
if (Lines.Count == 0)
{
message = "Ajoutez au moins une ligne au devis.";
return false;
}
foreach (var line in Lines)
{
if (string.IsNullOrWhiteSpace(line.Name))
{
message = "Chaque ligne doit avoir un nom.";
return false;
}
if (line.Name.Trim().Length > 256)
{
message = $"Le nom de la ligne « {line.Name.Trim()[..20]}… » dépasse 256 caractères.";
return false;
}
if (string.IsNullOrWhiteSpace(line.Description))
{
message = $"La ligne « {line.Name.Trim()} » doit avoir une description.";
return false;
}
if (line.Description.Trim().Length > 512)
{
message = $"La description de la ligne « {line.Name.Trim()} » dépasse 512 caractères.";
return false;
}
if (line.Count < 1)
{
message = $"La quantité de la ligne « {line.Name.Trim()} » doit être d'au moins 1.";
return false;
}
}
message = string.Empty;
return true;
}
private void OnLinesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged -= OnLinePropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged += OnLinePropertyChanged;
}
}
RaiseTotalsChanged();
}
private void OnLinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(EstimateLineItemViewModel.LineTotal)
or nameof(EstimateLineItemViewModel.Currency))
{
RaiseTotalsChanged();
}
}
private void RaiseTotalsChanged()
{
OnPropertyChanged(nameof(Total));
OnPropertyChanged(nameof(TotalLabel));
}
}

View file

@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
/// <summary>
/// Editable estimate line. <see cref="Count"/> is exposed as a
/// <see cref="decimal"/> so it binds directly to
/// <c>NumericUpDown.Value</c> (<c>decimal?</c>); it is rounded back
/// to an integer when the DTO is built.
/// </summary>
public partial class EstimateLineItemViewModel : ObservableObject
{
public long Id { get; set; }
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
[ObservableProperty]
public partial string Description { get; set; } = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal Count { get; set; } = 1m;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal UnitaryCost { get; set; }
[ObservableProperty]
public partial string Currency { get; set; } = "EUR";
public decimal LineTotal => Count * UnitaryCost;
public string LineTotalLabel => $"{LineTotal:0.00}";
}

View file

@ -22,6 +22,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
public const string SortByStatus = "Statut (en cours d'abord)";
private readonly BillingApiClient _billingClient;
private readonly EstimateApiClient? _estimateClient;
private readonly Settings? _settings;
private List<BillingQuerySummaryDto> _allQueries = new();
@ -42,6 +43,8 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
public partial string SelectedSortOption { get; set; } = SortByDate;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenSelectedEditorCommand))]
[NotifyCanExecuteChangedFor(nameof(CreateEstimateForSelectedCommand))]
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
[ObservableProperty]
@ -67,9 +70,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
protected set { _ = value; }
}
public ProviderOngoingRequestsPageViewModel(BillingApiClient billingClient, Settings? settings = null)
public ProviderOngoingRequestsPageViewModel(
BillingApiClient billingClient,
Settings? settings = null,
EstimateApiClient? estimateClient = null)
{
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_estimateClient = estimateClient;
_settings = settings;
if (_settings is not null)
@ -210,6 +217,33 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
}
}
private bool CanCreateEstimateForSelected() => SelectedQuery is not null && _estimateClient is not null;
[RelayCommand(CanExecute = nameof(CanCreateEstimateForSelected))]
public async Task CreateEstimateForSelectedAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
if (_estimateClient is null)
{
this.SetWarningStatus("Le client devis n'est pas disponible.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
partial void OnFilterTextChanged(string value)
{
ApplyFilter();

View file

@ -65,7 +65,9 @@ public class HomePageViewModel : ViewModelBase
throw new InvalidOperationException("Client billing indisponible.");
}
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings);
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}

View file

@ -0,0 +1,151 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel"
Header="Edition de devis">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="Demande"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Text="{Binding QueryDescription}"
TextWrapping="Wrap"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="Titre"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBox Grid.Row="1"
Grid.Column="2"
Text="{Binding EstimateTitle}"
PlaceholderText="Titre du devis" />
<TextBlock Grid.Row="2"
Grid.Column="0"
Text="Description"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBox Grid.Row="2"
Grid.Column="2"
Text="{Binding EstimateDescription}"
PlaceholderText="Description détaillée du devis"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="70" />
</Grid>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="Lignes du devis"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter une ligne"
Command="{Binding AddLineCommand}" />
<Button Grid.Column="3"
Content="Retirer la ligne"
Command="{Binding RemoveLineCommand}" />
</Grid>
<ListBox ItemsSource="{Binding Lines}"
SelectedItem="{Binding SelectedLine, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EstimateLineItemViewModel">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,8">
<Grid ColumnDefinitions="*,8,110,8,130,8,90"
RowDefinitions="Auto,Auto"
RowSpacing="6">
<TextBox Grid.Row="0"
Grid.Column="0"
Text="{Binding Name}"
PlaceholderText="Nom de la ligne" />
<NumericUpDown Grid.Row="0"
Grid.Column="2"
Value="{Binding Count}"
Minimum="1"
Increment="1"
FormatString="0" />
<NumericUpDown Grid.Row="0"
Grid.Column="4"
Value="{Binding UnitaryCost}"
Increment="0.5"
FormatString="0.00" />
<TextBlock Grid.Row="0"
Grid.Column="6"
Text="{Binding LineTotalLabel}"
VerticalAlignment="Center"
HorizontalAlignment="Right"
FontWeight="SemiBold" />
<TextBox Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="7"
Text="{Binding Description}"
PlaceholderText="Description de la ligne" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="1"
Text="{Binding TotalLabel, StringFormat='Total : {0}'}"
FontSize="16"
FontWeight="Bold"
HorizontalAlignment="Right" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<Button Grid.Column="0"
Content="{Binding SendLabel}"
Command="{Binding SendCommand}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
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 EstimateEditionPage : ContentPage
{
public EstimateEditionPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -87,6 +87,9 @@
<Button Content="Ouvrir en édition"
Command="{Binding OpenSelectedEditorCommand}"
/>
<Button Content="Créer un devis"
Command="{Binding CreateEstimateForSelectedCommand}"
/>
</StackPanel>
</Grid>

View file

@ -15,10 +15,11 @@
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
<ScrollViewer
VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
>
<StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">