diff --git a/contrib/Makefile b/contrib/Makefile index 79145668f..4e4ef4df6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -95,6 +95,15 @@ showConfig: @echo CONFIGURATION: $(CONFIGURATION) @echo BASEAPPDIR: $(BASEAPPDIR) +showApiLogs: + @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log + +showOrgLogs: + @sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log + +showBlogsLogs: + @sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log + clean: @rm -rf generated diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs new file mode 100644 index 000000000..db70e25fa --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -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(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(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 CallAsync(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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 821a778dd..8ecc5bb55 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -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); diff --git a/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs index 945072870..cfb300316 100644 --- a/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ProviderOngoingRequestsPageViewModelTests.cs @@ -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(); diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 0ebd2af19..6bd54ca20 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -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(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + // ViewModels services.AddSingleton(settings); services.AddSingleton(api); @@ -69,6 +72,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(userSearchClient); services.AddSingleton(activityClient); services.AddSingleton(billingClient); + services.AddSingleton(estimateClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 72bd2559e..6f8c5d902 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -53,6 +53,7 @@ public class ViewLocator : IDataTemplate BillingQueriesPageViewModel => services.GetRequiredService(), BillingQueryDetailsPageViewModel => services.GetRequiredService(), ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), + EstimateEditionPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs new file mode 100644 index 000000000..8f007dc08 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs @@ -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; + +/// +/// Edition d'un devis (Estimate) créé en réponse à une demande +/// client () consultée depuis la +/// page « Mes demandes en cours ». L'envoi poste le devis sur +/// api/v1/estimate; côté serveur, la commande liée +/// () est alors marquée comme +/// validée par le prestataire. +/// +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 Lines { get; set; } = new(); + + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))] + public partial EstimateLineItemViewModel? SelectedLine { get; set; } + + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))] + public partial bool IsBusy { get; set; } + + /// + /// True une fois le devis accepté par le serveur: l'envoi est + /// désactivé pour éviter les doublons, il ne reste que « Retour ». + /// + [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()) + { + item.PropertyChanged -= OnLinePropertyChanged; + } + } + + if (e.NewItems is not null) + { + foreach (var item in e.NewItems.OfType()) + { + 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)); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs new file mode 100644 index 000000000..864906cdd --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateLineItemViewModel.cs @@ -0,0 +1,35 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace PostIt.ViewModels; + +/// +/// Editable estimate line. is exposed as a +/// so it binds directly to +/// NumericUpDown.Value (decimal?); it is rounded back +/// to an integer when the DTO is built. +/// +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}"; +} diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 562f24988..71e979e66 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -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 _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(); diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 673177863..fd2ff1091 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -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(); + + var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient); await vm.InitializeAsync(); await app.PushPageAsync(vm); } diff --git a/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml new file mode 100644 index 000000000..7b25ab226 --- /dev/null +++ b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml @@ -0,0 +1,151 @@ + + + + + + + + +