This commit is contained in:
parent
f70f487777
commit
848f8be42f
19 changed files with 1235 additions and 14 deletions
270
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal file
270
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
151
src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
Normal file
151
src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,9 @@
|
|||
<Button Content="Ouvrir en édition"
|
||||
Command="{Binding OpenSelectedEditorCommand}"
|
||||
/>
|
||||
<Button Content="Créer un devis"
|
||||
Command="{Binding CreateEstimateForSelectedCommand}"
|
||||
/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue