diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2efb4a..e5f285ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,17 @@ ### Added +* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante. +* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress). + ### Changed +* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour. + ### Fixed +* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail. + ## [1.0.8-rc9] - unstable ### Added diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index 4aa69978..eef3fe21 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -132,15 +132,71 @@ public class BillingCommandPageViewModelTests Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32()); } + [Fact] + public async Task InitializeAsync_with_existing_brush_query_prefills_and_submit_updates_query() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 30, Title = "Femme · Cheveux longs", Details = "Coupe · Brushing" }, + new() { Id = 31, Title = "Homme · Cheveux courts", Details = "Coupe" }, + } + }; + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, + client); + + await vm.InitializeAsync(new BillingQueryDetailsDto + { + Id = 77, + BillingCode = "Brush", + ActivityCode = "brush", + PerformerId = "perf-2", + ClientId = "cli-1", + EventDate = new DateTime(2026, 9, 2, 14, 30, 0, DateTimeKind.Utc), + Consent = true, + Status = QueryStatus.Accepted, + PrestationId = 30, + AdditionalInfo = "Ancienne note", + Location = new BillingLocationDto + { + Address = "1 rue du Test", + Latitude = 48.8566, + Longitude = 2.3522, + } + }); + + vm.SelectedPrestation = vm.AvailablePrestations[1]; + vm.AdditionalInfo = "Note mise à jour"; + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal(HttpMethod.Put, api.LastMethod); + Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath); + Assert.True(vm.IsEditingExisting); + Assert.Equal("Mettre à jour la commande", vm.SubmitLabel); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal(77, json.RootElement.GetProperty("Id").GetInt32()); + Assert.Equal(31, json.RootElement.GetProperty("PrestationId").GetInt32()); + Assert.Equal("Note mise à jour", json.RootElement.GetProperty("AdditionalInfo").GetString()); + Assert.Equal((int)QueryStatus.Accepted, json.RootElement.GetProperty("Status").GetInt32()); + } + private sealed class RecordingApi : IYavscApiClient { public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } public string? LastPath { get; private set; } public object? LastBody { get; private set; } public List? HairPrestations { get; init; } public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { + LastMethod = method; LastPath = path; LastBody = body; if (typeof(T) == typeof(List)) @@ -152,6 +208,7 @@ public class BillingCommandPageViewModelTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { + LastMethod = method; LastPath = path; LastBody = body; return Task.CompletedTask; diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs index f8a3348b..a5d37532 100644 --- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -22,9 +22,33 @@ public class BillingQueriesPageViewModelTests await vm.InitializeAsync(); Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single()); - Assert.Equal(1, vm.Queries.Count); - Assert.Equal("Rendez-vous #1", vm.Queries[0].Description); - Assert.Contains("1 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(3, vm.Queries.Count); + Assert.Contains(vm.Queries, q => q.Description == "Rendez-vous #1"); + Assert.Contains("3 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RefreshAsync_in_readonly_ongoing_mode_keeps_only_ongoing_statuses_and_disables_open() + { + var api = new StubBillingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingQueriesPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + client, + isReadOnly: true, + ongoingOnly: true); + + await vm.InitializeAsync(); + + Assert.Equal(2, vm.Queries.Count); + Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase)); + Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.False(vm.CanOpenDetails); + + vm.SelectedQuery = vm.Queries[0]; + Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null)); } private sealed class StubBillingApi : IYavscApiClient @@ -70,6 +94,26 @@ public class BillingQueriesPageViewModelTests Status = QueryStatus.Accepted, Description = "Autre performer", EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 14, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.InProgress, + Description = "En cours", + EventDate = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 15, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Rejected, + Description = "Rejetée", + EventDate = new DateTime(2026, 9, 5, 10, 0, 0, DateTimeKind.Utc), } }; diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs index 68516ea9..c9050cbf 100644 --- a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs @@ -61,6 +61,12 @@ public partial class BillingCommandPageViewModel : ViewModelBase [ObservableProperty] public partial string AdditionalInfo { get; set; } = string.Empty; + [ObservableProperty] + public partial long? ExistingQueryId { get; set; } + + [ObservableProperty] + public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted; + public string Title => Form.Title; public string PerformerLabel => Performer.UserName; public string ActivityLabel => Activity.Name; @@ -73,6 +79,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase public bool ShowsSinglePrestation => IsBrush; public bool ShowsMultiplePrestations => IsMultiBrush; public string BillingRoute => $"/billing/{Form.ActionName}"; + public bool IsEditingExisting => ExistingQueryId.HasValue; + public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande"; public string SupportMessage => IsSupported ? IsRdv ? "Complétez les informations du rendez-vous puis postez la commande." @@ -108,10 +116,21 @@ public partial class BillingCommandPageViewModel : ViewModelBase StatusMessage = SupportMessage; } - public async Task InitializeAsync() + partial void OnExistingQueryIdChanged(long? value) + { + OnPropertyChanged(nameof(IsEditingExisting)); + OnPropertyChanged(nameof(SubmitLabel)); + } + + public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) { if (!IsBrush && !IsMultiBrush) { + if (existingQuery is not null) + { + ApplyExistingQuery(existingQuery); + } + return; } @@ -139,6 +158,11 @@ public partial class BillingCommandPageViewModel : ViewModelBase { IsBusy = false; } + + if (existingQuery is not null) + { + ApplyExistingQuery(existingQuery); + } } [RelayCommand] @@ -196,18 +220,44 @@ public partial class BillingCommandPageViewModel : ViewModelBase Longitude = longitude, }; + var payload = new BillingQueryDetailsDto + { + Id = ExistingQueryId ?? 0, + BillingCode = Form.ActionName, + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent = Consent, + EventDate = eventDate, + Status = CommandStatus, + Reason = Reason.Trim(), + AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), + Location = new BillingLocationDto + { + Address = location.Address, + Latitude = location.Latitude, + Longitude = location.Longitude, + } + }; + if (IsRdv) { - await _billingClient.CreateAsync(Form.ActionName, new + if (IsEditingExisting) { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = eventDate, - Location = location, - Reason = Reason.Trim(), - Status = QueryStatus.Inserted, - }).ConfigureAwait(true); + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = eventDate, + Location = location, + Reason = payload.Reason, + Status = payload.Status, + }).ConfigureAwait(true); + } } else if (IsBrush) { @@ -217,17 +267,26 @@ public partial class BillingCommandPageViewModel : ViewModelBase return; } - await _billingClient.CreateAsync(Form.ActionName, new + payload.PrestationId = SelectedPrestation.Id; + + if (IsEditingExisting) { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = (DateTime?)eventDate, - Location = location, - PrestationId = SelectedPrestation.Id, - AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), - Status = QueryStatus.Inserted, - }).ConfigureAwait(true); + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = (DateTime?)eventDate, + Location = location, + PrestationId = SelectedPrestation.Id, + AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), + Status = payload.Status, + }).ConfigureAwait(true); + } } else if (IsMultiBrush) { @@ -238,19 +297,30 @@ public partial class BillingCommandPageViewModel : ViewModelBase return; } - await _billingClient.CreateAsync(Form.ActionName, new + payload.PrestationIds = selectedPrestations.Select(x => x.Id).ToList(); + + if (IsEditingExisting) { - ActivityCode = Activity.Code, - PerformerId = Performer.PerformerId, - Consent, - EventDate = eventDate, - Location = location, - Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), - Status = QueryStatus.Inserted, - }).ConfigureAwait(true); + await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true); + } + else + { + await _billingClient.CreateAsync(Form.ActionName, new + { + ActivityCode = Activity.Code, + PerformerId = Performer.PerformerId, + Consent, + EventDate = eventDate, + Location = location, + Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), + Status = payload.Status, + }).ConfigureAwait(true); + } } - StatusMessage = $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; + StatusMessage = IsEditingExisting + ? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}." + : $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { @@ -266,6 +336,47 @@ public partial class BillingCommandPageViewModel : ViewModelBase } } + private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) + { + ExistingQueryId = existingQuery.Id; + CommandStatus = existingQuery.Status; + Consent = existingQuery.Consent; + Reason = existingQuery.Reason ?? string.Empty; + AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty; + + if (existingQuery.EventDate is not null) + { + EventDateText = existingQuery.EventDate.Value + .ToLocalTime() + .ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + } + + if (existingQuery.Location is not null) + { + Address = existingQuery.Location.Address ?? string.Empty; + LatitudeText = existingQuery.Location.Latitude.ToString(CultureInfo.InvariantCulture); + LongitudeText = existingQuery.Location.Longitude.ToString(CultureInfo.InvariantCulture); + } + + if (IsBrush && existingQuery.PrestationId is not null) + { + SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value); + } + + if (IsMultiBrush) + { + var selectedIds = existingQuery.PrestationIds is null + ? new HashSet() + : new HashSet(existingQuery.PrestationIds); + foreach (var item in MultiPrestations) + { + item.IsSelected = selectedIds.Contains(item.Id); + } + } + + StatusMessage = $"Commande #{existingQuery.Id} chargée."; + } + private bool TryParseEventDate(out DateTime eventDate) { return DateTime.TryParse( diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs index 7629ac42..d680ba74 100644 --- a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs @@ -4,8 +4,11 @@ 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; using Yavsc.Api.Client; using Yavsc.Abstract.Workflow; @@ -18,18 +21,26 @@ public partial class BillingQueriesPageViewModel : ViewModelBase public ActivityBrowseItemDto Activity { get; } public ActivityUserDisplayItem Performer { get; } public CommandFormSummaryDto Form { get; } + public bool IsReadOnly { get; } + public bool OngoingOnly { get; } [ObservableProperty] public partial ObservableCollection Queries { get; set; } = new(); + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))] + public partial BillingQueryDisplayItem? SelectedQuery { get; set; } + [ObservableProperty] public partial bool IsBusy { get; set; } [ObservableProperty] public partial string StatusMessage { get; set; } = "Chargement des commandes..."; - public string Title => $"Commandes {Form.Title}"; + public string Title => IsReadOnly + ? $"Demandes en cours ({Form.Title})" + : $"Commandes {Form.Title}"; public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; + public bool CanOpenDetails => !IsReadOnly; public override bool CanNavigateNext { @@ -47,16 +58,22 @@ public partial class BillingQueriesPageViewModel : ViewModelBase ActivityBrowseItemDto activity, ActivityUserDisplayItem performer, CommandFormSummaryDto form, - BillingApiClient billingClient) + BillingApiClient billingClient, + bool isReadOnly = false, + bool ongoingOnly = false) { Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Form = form ?? throw new ArgumentNullException(nameof(form)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + IsReadOnly = isReadOnly; + OngoingOnly = ongoingOnly; } public Task InitializeAsync() => RefreshAsync(); + private bool CanOpenSelectedQuery() => !IsReadOnly && SelectedQuery is not null; + [RelayCommand] public async Task RefreshAsync() { @@ -66,15 +83,14 @@ public partial class BillingQueriesPageViewModel : ViewModelBase var list = await _billingClient.GetQuerySummariesAsync(Form.ActionName).ConfigureAwait(true); var filtered = (list ?? new()) .Where(q => q.ActivityCode == Activity.Code && q.PerformerId == Performer.PerformerId) + .Where(q => !OngoingOnly || IsOngoingStatus(q.Status)) .OrderByDescending(q => q.EventDate ?? DateTime.MinValue) .ThenByDescending(q => q.Id) .Select(BillingQueryDisplayItem.FromDto) .ToList(); Queries = new ObservableCollection(filtered); - StatusMessage = filtered.Count == 0 - ? "Aucune commande trouvée pour ce formulaire." - : $"{filtered.Count} commande(s) chargée(s)."; + StatusMessage = BuildLoadedStatusMessage(filtered.Count); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { @@ -91,4 +107,67 @@ public partial class BillingQueriesPageViewModel : ViewModelBase IsBusy = false; } } + + [RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))] + public async Task OpenSelectedQueryAsync() + { + if (IsReadOnly) + { + StatusMessage = "Mode lecture seule: l'ouverture en modification est désactivée."; + return; + } + + if (SelectedQuery is null) + { + StatusMessage = "Sélectionnez une commande."; + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + IsBusy = true; + try + { + var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); + var vm = new BillingCommandPageViewModel(Activity, Performer, Form, _billingClient); + await vm.InitializeAsync(details).ConfigureAwait(true); + await app.PushPageAsync(vm).ConfigureAwait(true); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors de l'ouverture: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private string BuildLoadedStatusMessage(int count) + { + if (count == 0) + { + return OngoingOnly + ? "Aucune demande en cours pour ce formulaire." + : "Aucune commande trouvée pour ce formulaire."; + } + + if (OngoingOnly) + { + return $"{count} demande(s) en cours chargée(s) (lecture seule)."; + } + + return $"{count} commande(s) chargée(s)."; + } + + private static bool IsOngoingStatus(QueryStatus status) + => status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; } \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs index 426533a7..e1453c11 100644 --- a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs @@ -21,7 +21,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase [ObservableProperty] public partial ObservableCollection Forms { get; set; } - [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand))] + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))] public partial CommandFormSummaryDto? SelectedForm { get; set; } [ObservableProperty] @@ -64,6 +64,8 @@ public partial class CommandFormsPageViewModel : ViewModelBase private bool CanOpenQueries() => SelectedForm is not null; + private bool CanOpenOngoingQueries() => SelectedForm is not null; + [RelayCommand(CanExecute = nameof(CanOpenSelectedForm))] private async Task OpenSelectedFormAsync() { @@ -103,4 +105,30 @@ public partial class CommandFormsPageViewModel : ViewModelBase await vm.InitializeAsync(); await app.PushPageAsync(vm); } + + [RelayCommand(CanExecute = nameof(CanOpenOngoingQueries))] + private async Task OpenOngoingQueriesAsync() + { + if (SelectedForm is null) + { + StatusMessage = "Sélectionnez un formulaire."; + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = new BillingQueriesPageViewModel( + Activity, + Performer, + SelectedForm, + _billingClient, + isReadOnly: true, + ongoingOnly: true); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } } \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/BillingCommandPage.axaml b/src/PostIt/PostIt/Views/BillingCommandPage.axaml index 251376e1..2dba7336 100644 --- a/src/PostIt/PostIt/Views/BillingCommandPage.axaml +++ b/src/PostIt/PostIt/Views/BillingCommandPage.axaml @@ -108,7 +108,7 @@