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/ROADMAP.md b/ROADMAP.md index 4c00e629..364b98bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons : > > Chaque jalon a un **critère de sortie** vérifiable. -### Jalon 0 — Fondations techniques *(en cours)* +### Jalon 0 — Fondations techniques > Cible : pouvoir parler du domaine sans se battre avec le runtime. @@ -81,7 +81,7 @@ Trois principes non négociables traversent tous les jalons : --- -### Jalon 1 — Prestation signée de bout en bout +### Jalon 1 — Prestation signée de bout en bout *(en cours)* > Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications. diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index da166fa6..c2fab4c3 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -12,12 +12,17 @@ public class ActivitiesPageViewModelTests { var api = new StubActivityApi(); var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken); await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken); + await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, TestContext.Current.CancellationToken); + await billingClient.GetQuerySummariesAsync("Rdv", TestContext.Current.CancellationToken); Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]); Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]); + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]); + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]); } [Fact] @@ -25,7 +30,8 @@ public class ActivitiesPageViewModelTests { var api = new StubActivityApi(); var client = new ActivityApiClient(api, "https://business.example/api/v1/"); - var vm = new ActivitiesPageViewModel(client); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ActivitiesPageViewModel(client, billingClient); await vm.RefreshAsync(); @@ -76,6 +82,10 @@ public class ActivitiesPageViewModelTests Name = "Brush", Description = "Coiffure à domicile", PerformerCount = 1, + Forms = new List + { + new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" } + }, Children = new List { new() @@ -85,6 +95,10 @@ public class ActivitiesPageViewModelTests Description = "Spécialisation premium", ParentCode = "brush", PerformerCount = 1, + Forms = new List + { + new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" } + } } } } diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs new file mode 100644 index 00000000..eef3fe21 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -0,0 +1,219 @@ +using System.Net.Http; +using System.Text.Json; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Haircut; + +namespace PostIt.Tests; + +public class BillingCommandPageViewModelTests +{ + [Fact] + public async Task SubmitAsync_posts_rdv_payload_to_selected_billing_route() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, + client) + { + EventDateText = "2026-09-02 14:30", + Reason = "Point de cadrage", + Address = "1 rue du Test", + LatitudeText = "48.8566", + LongitudeText = "2.3522", + Consent = true, + }; + + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.LastPath); + Assert.NotNull(api.LastBody); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal("dev", json.RootElement.GetProperty("ActivityCode").GetString()); + Assert.Equal("perf-1", json.RootElement.GetProperty("PerformerId").GetString()); + Assert.Equal("Point de cadrage", json.RootElement.GetProperty("Reason").GetString()); + Assert.Equal((int)QueryStatus.Inserted, json.RootElement.GetProperty("Status").GetInt32()); + } + + [Fact] + public async Task SubmitAsync_refuses_unsupported_billing_code() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "book", Name = "Book" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + new CommandFormSummaryDto { Id = 13, ActionName = "Book", Title = "Réservation" }, + client); + + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Null(api.LastPath); + Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 10, Title = "Femme · Cheveux mi-longs", Details = "Coupe · Brushing" }, + new() { Id = 11, Title = "Homme · Cheveux courts", Details = "Coupe · Coiffage" }, + } + }; + 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) + { + EventDateText = "2026-09-02 14:30", + Address = "1 rue du Test", + LatitudeText = "48.8566", + LongitudeText = "2.3522", + Consent = true, + AdditionalInfo = "Prévoir shampoing", + }; + + await vm.InitializeAsync(); + vm.SelectedPrestation = vm.AvailablePrestations[1]; + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/Brush", api.LastPath); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal(11, json.RootElement.GetProperty("PrestationId").GetInt32()); + Assert.Equal("Prévoir shampoing", json.RootElement.GetProperty("AdditionalInfo").GetString()); + } + + [Fact] + public async Task InitializeAsync_loads_prestations_for_mbrush_and_submit_posts_selected_prestations() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 21, Title = "Femme · Cheveux longs", Details = "Coupe · Couleur" }, + new() { Id = 22, Title = "Enfant · Cheveux courts", Details = "Coupe · Sans technique" }, + } + }; + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "mbrush", Name = "MBrush" }, + new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" }, + new CommandFormSummaryDto { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }, + client) + { + EventDateText = "2026-09-03 10:00", + Address = "2 rue du Test", + LatitudeText = "48.8567", + LongitudeText = "2.3523", + Consent = true, + }; + + await vm.InitializeAsync(); + vm.MultiPrestations[0].IsSelected = true; + vm.MultiPrestations[1].IsSelected = true; + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + var prestations = json.RootElement.GetProperty("Prestations"); + Assert.Equal(2, prestations.GetArrayLength()); + Assert.Equal(21, prestations[0].GetProperty("PrestationId").GetInt32()); + 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)) + { + return Task.FromResult((T)(object)(HairPrestations ?? new List())); + } + 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 ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs new file mode 100644 index 00000000..a5d37532 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class BillingQueriesPageViewModelTests +{ + [Fact] + public async Task RefreshAsync_filters_queries_by_selected_activity_and_performer() + { + 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); + + await vm.InitializeAsync(); + + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single()); + 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 + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var data = new List + { + new() + { + Id = 11, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Inserted, + Description = "Rendez-vous #1", + Reason = "Point de cadrage", + EventDate = new DateTime(2026, 9, 1, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 12, + ActivityCode = "other", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Accepted, + Description = "Autre activité", + EventDate = new DateTime(2026, 9, 2, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 13, + ActivityCode = "dev", + PerformerId = "perf-2", + ClientId = "cli-1", + 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), + } + }; + + return Task.FromResult((T)(object)data); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 98b6efb3..e86d1c4c 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -24,6 +24,7 @@ public static class ServiceCollectionHelpers var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); + var billingClient = new BillingApiClient(api, settings.BusinessApiUrl); var userDirectory = new UserDirectory(userSearchClient); // Vues @@ -47,6 +48,9 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); @@ -55,11 +59,13 @@ public static class ServiceCollectionHelpers services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); services.AddSingleton(activityClient); + services.AddSingleton(billingClient); services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); // Dialogs (modal-light pages): the ViewLocator resolves // them when a caller pushes a PostAclDialogViewModel or diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index a81bf6ce..2fd51633 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -40,6 +40,9 @@ public class ViewLocator : IDataTemplate Settings => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), ActivitiesPageViewModel => services.GetRequiredService(), + CommandFormsPageViewModel => services.GetRequiredService(), + BillingCommandPageViewModel => services.GetRequiredService(), + BillingQueriesPageViewModel => services.GetRequiredService(), SignaturePageViewModel => services.GetRequiredService(), AddCircleMemberDialogViewModel => services.GetRequiredService(), CirclesPageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs index e39a2c18..5268d07a 100644 --- a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs @@ -4,8 +4,10 @@ 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.Abstract.Workflow; using Yavsc.Api.Client; @@ -14,6 +16,7 @@ namespace PostIt.ViewModels; public partial class ActivitiesPageViewModel : ViewModelBase { private readonly ActivityApiClient _client; + private readonly BillingApiClient _billingClient; private bool _syncingSelection; [ObservableProperty] @@ -31,6 +34,9 @@ public partial class ActivitiesPageViewModel : ViewModelBase [ObservableProperty] public partial ObservableCollection Performers { get; set; } = new(); + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenCommandFormsCommand))] + public partial ActivityUserDisplayItem? SelectedPerformer { get; set; } + [ObservableProperty] public partial bool IsBusy { get; set; } @@ -40,6 +46,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity; public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)"; public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)"; + public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0; public override bool CanNavigateNext { @@ -53,9 +60,10 @@ public partial class ActivitiesPageViewModel : ViewModelBase protected set { _ = value; } } - public ActivitiesPageViewModel(ActivityApiClient client) + public ActivitiesPageViewModel(ActivityApiClient client, BillingApiClient billingClient) { _client = client ?? throw new ArgumentNullException(nameof(client)); + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); } partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value) @@ -154,11 +162,13 @@ public partial class ActivitiesPageViewModel : ViewModelBase OnPropertyChanged(nameof(CurrentActivity)); OnPropertyChanged(nameof(SelectedActivityLabel)); OnPropertyChanged(nameof(CurrentActivityLabel)); + OnPropertyChanged(nameof(CurrentFormCount)); Specializations = new ObservableCollection(activity?.Children ?? new()); if (activity is null) { Performers = new ObservableCollection(); + SelectedPerformer = null; return; } @@ -179,6 +189,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase OnPropertyChanged(nameof(CurrentActivity)); OnPropertyChanged(nameof(CurrentActivityLabel)); + OnPropertyChanged(nameof(CurrentFormCount)); if (specialization is null) { @@ -200,21 +211,47 @@ public partial class ActivitiesPageViewModel : ViewModelBase var list = await _client.GetUsersAsync(activity.Code); Performers = new ObservableCollection((list ?? new()) .Select(ActivityUserDisplayItem.FromDto)); + SelectedPerformer = null; StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)"; } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { Performers = new ObservableCollection(); + SelectedPerformer = null; StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; } catch (Exception ex) { Performers = new ObservableCollection(); + SelectedPerformer = null; StatusMessage = $"Erreur: {ex.Message}"; } finally { IsBusy = false; + OpenCommandFormsCommand.NotifyCanExecuteChanged(); } } + + private bool CanOpenCommandForms() + => SelectedPerformer is not null && CurrentActivity?.Forms?.Count > 0; + + [RelayCommand(CanExecute = nameof(CanOpenCommandForms))] + private async Task OpenCommandFormsAsync() + { + if (SelectedPerformer is null || CurrentActivity is null) + { + StatusMessage = "Sélectionnez un utilisateur et une activité avec formulaire."; + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = new CommandFormsPageViewModel(CurrentActivity, SelectedPerformer, _billingClient); + await app.PushPageAsync(vm); + } } diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs new file mode 100644 index 00000000..c9050cbf --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; +using Yavsc.Models.Haircut; +using Yavsc.Models.Relationship; + +namespace PostIt.ViewModels; + +public partial class BillingCommandPageViewModel : ViewModelBase +{ + private readonly BillingApiClient _billingClient; + + public ActivityBrowseItemDto Activity { get; } + public ActivityUserDisplayItem Performer { get; } + public CommandFormSummaryDto Form { get; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } + + [ObservableProperty] + public partial string EventDateText { get; set; } + + [ObservableProperty] + public partial string Reason { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Address { get; set; } = string.Empty; + + [ObservableProperty] + public partial string LatitudeText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string LongitudeText { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool Consent { get; set; } = true; + + [ObservableProperty] + public partial ObservableCollection AvailablePrestations { get; set; } = new(); + + [ObservableProperty] + public partial HairPrestationDto? SelectedPrestation { get; set; } + + [ObservableProperty] + public partial ObservableCollection MultiPrestations { get; set; } = new(); + + [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; + public bool IsSupported => IsRdv || IsBrush || IsMultiBrush; + public bool IsRdv => string.Equals(Form.ActionName, BillingCodes.Rdv, StringComparison.Ordinal); + public bool IsBrush => string.Equals(Form.ActionName, BillingCodes.Brush, StringComparison.Ordinal); + public bool IsMultiBrush => string.Equals(Form.ActionName, BillingCodes.MBrush, StringComparison.Ordinal); + public bool ShowsReason => IsRdv; + public bool ShowsAdditionalInfo => IsBrush; + 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." + : IsBrush + ? "Choisissez une prestation coiffure puis postez la commande." + : "Choisissez une ou plusieurs prestations coiffure puis postez la commande." + : $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt."; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public BillingCommandPageViewModel( + ActivityBrowseItemDto activity, + ActivityUserDisplayItem performer, + CommandFormSummaryDto form, + BillingApiClient billingClient) + { + 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)); + + EventDateText = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + StatusMessage = SupportMessage; + } + + 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; + } + + IsBusy = true; + try + { + var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName).ConfigureAwait(true); + AvailablePrestations = new ObservableCollection(prestations ?? new List()); + SelectedPrestation = AvailablePrestations.FirstOrDefault(); + MultiPrestations = new ObservableCollection(AvailablePrestations.Select(SelectableHairPrestationItem.FromDto)); + + StatusMessage = AvailablePrestations.Count == 0 + ? "Aucune prestation coiffure disponible." + : SupportMessage; + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}"; + } + finally + { + IsBusy = false; + } + + if (existingQuery is not null) + { + ApplyExistingQuery(existingQuery); + } + } + + [RelayCommand] + private async Task SubmitAsync() + { + if (!IsSupported) + { + StatusMessage = SupportMessage; + return; + } + + if (!Consent) + { + StatusMessage = "Le consentement est requis pour poster la commande."; + return; + } + + if (!TryParseEventDate(out var eventDate)) + { + StatusMessage = "La date doit être saisie au format yyyy-MM-dd HH:mm."; + return; + } + + if (IsRdv && string.IsNullOrWhiteSpace(Reason)) + { + StatusMessage = "Le motif du rendez-vous est requis."; + return; + } + + if (string.IsNullOrWhiteSpace(Address)) + { + StatusMessage = "L'adresse du rendez-vous est requise."; + return; + } + + if (!TryParseCoordinate(LatitudeText, out var latitude)) + { + StatusMessage = "Latitude invalide."; + return; + } + + if (!TryParseCoordinate(LongitudeText, out var longitude)) + { + StatusMessage = "Longitude invalide."; + return; + } + + IsBusy = true; + try + { + var location = new Location + { + Address = Address.Trim(), + Latitude = latitude, + 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) + { + if (IsEditingExisting) + { + 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) + { + if (SelectedPrestation is null) + { + StatusMessage = "Sélectionnez une prestation coiffure."; + return; + } + + payload.PrestationId = SelectedPrestation.Id; + + if (IsEditingExisting) + { + 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) + { + var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList(); + if (selectedPrestations.Count == 0) + { + StatusMessage = "Sélectionnez au moins une prestation coiffure."; + return; + } + + payload.PrestationIds = selectedPrestations.Select(x => x.Id).ToList(); + + if (IsEditingExisting) + { + 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 = 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) + { + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur lors de l'envoi: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + 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( + EventDateText, + CultureInfo.CurrentCulture, + DateTimeStyles.AssumeLocal, + out eventDate) + || DateTime.TryParse( + EventDateText, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out eventDate); + } + + private static bool TryParseCoordinate(string text, out double value) + { + return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, out value) + || double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs new file mode 100644 index 00000000..d680ba74 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.ObjectModel; +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; + +namespace PostIt.ViewModels; + +public partial class BillingQueriesPageViewModel : ViewModelBase +{ + private readonly BillingApiClient _billingClient; + + 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 => IsReadOnly + ? $"Demandes en cours ({Form.Title})" + : $"Commandes {Form.Title}"; + public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; + public bool CanOpenDetails => !IsReadOnly; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public BillingQueriesPageViewModel( + ActivityBrowseItemDto activity, + ActivityUserDisplayItem performer, + CommandFormSummaryDto form, + 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() + { + IsBusy = true; + try + { + 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 = BuildLoadedStatusMessage(filtered.Count); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + Queries = new ObservableCollection(); + StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + Queries = new ObservableCollection(); + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + 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/BillingQueryDisplayItem.cs b/src/PostIt/PostIt/ViewModels/BillingQueryDisplayItem.cs new file mode 100644 index 00000000..bc437337 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/BillingQueryDisplayItem.cs @@ -0,0 +1,35 @@ +using System; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public sealed class BillingQueryDisplayItem +{ + public long Id { get; init; } + public string Description { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + public string StatusLabel { get; init; } = string.Empty; + public string EventDateLabel { get; init; } = string.Empty; + public string BillingCode { get; init; } = string.Empty; + + public static BillingQueryDisplayItem FromDto(BillingQuerySummaryDto dto) + { + var summary = !string.IsNullOrWhiteSpace(dto.Reason) + ? dto.Reason + : !string.IsNullOrWhiteSpace(dto.AdditionalInfo) + ? dto.AdditionalInfo + : dto.Description; + + return new BillingQueryDisplayItem + { + Id = dto.Id, + Description = string.IsNullOrWhiteSpace(dto.Description) + ? $"Commande #{dto.Id}" + : dto.Description, + Summary = summary, + StatusLabel = dto.Status.ToString(), + EventDateLabel = dto.EventDate?.ToLocalTime().ToString("g") ?? "Date non précisée", + BillingCode = dto.BillingCode, + }; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs new file mode 100644 index 00000000..e1453c11 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class CommandFormsPageViewModel : ViewModelBase +{ + private readonly BillingApiClient _billingClient; + + public ActivityBrowseItemDto Activity { get; } + public ActivityUserDisplayItem Performer { get; } + + [ObservableProperty] + public partial ObservableCollection Forms { get; set; } + + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))] + public partial CommandFormSummaryDto? SelectedForm { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } + + public string Title => $"Formulaires pour {Performer.UserName}"; + public string ContextLabel => $"{Activity.Name} · {Forms.Count} formulaire(s)"; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public CommandFormsPageViewModel( + ActivityBrowseItemDto activity, + ActivityUserDisplayItem performer, + BillingApiClient billingClient) + { + Activity = activity ?? throw new ArgumentNullException(nameof(activity)); + Performer = performer ?? throw new ArgumentNullException(nameof(performer)); + _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + + Forms = new ObservableCollection((activity.Forms ?? new()) + .OrderBy(f => f.Title) + .ThenBy(f => f.ActionName)); + SelectedForm = Forms.FirstOrDefault(); + StatusMessage = Forms.Count == 0 + ? "Aucun formulaire n'est disponible pour cette activité." + : "Choisissez le formulaire à utiliser."; + } + + private bool CanOpenSelectedForm() => SelectedForm is not null; + + private bool CanOpenQueries() => SelectedForm is not null; + + private bool CanOpenOngoingQueries() => SelectedForm is not null; + + [RelayCommand(CanExecute = nameof(CanOpenSelectedForm))] + private async Task OpenSelectedFormAsync() + { + 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 BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + + [RelayCommand(CanExecute = nameof(CanOpenQueries))] + private async Task OpenQueriesAsync() + { + 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); + 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/ViewModels/SelectableHairPrestationItem.cs b/src/PostIt/PostIt/ViewModels/SelectableHairPrestationItem.cs new file mode 100644 index 00000000..ee07bb97 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/SelectableHairPrestationItem.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Yavsc.Models.Haircut; + +namespace PostIt.ViewModels; + +public partial class SelectableHairPrestationItem : ObservableObject +{ + public long Id { get; init; } + public string Title { get; init; } = string.Empty; + public string Details { get; init; } = string.Empty; + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public static SelectableHairPrestationItem FromDto(HairPrestationDto dto) + => new() + { + Id = dto.Id, + Title = dto.Title, + Details = dto.Details, + }; +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml b/src/PostIt/PostIt/Views/ActivitiesPage.axaml index 8dd88c6f..31d7be5f 100644 --- a/src/PostIt/PostIt/Views/ActivitiesPage.axaml +++ b/src/PostIt/PostIt/Views/ActivitiesPage.axaml @@ -65,12 +65,14 @@ - + - + @@ -104,6 +106,10 @@ +