RdvQuery from PostIt
This commit is contained in:
parent
ec2c405e28
commit
9d97994e76
19 changed files with 912 additions and 8 deletions
|
|
@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons :
|
||||||
>
|
>
|
||||||
> Chaque jalon a un **critère de sortie** vérifiable.
|
> 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.
|
> 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.
|
> Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ public class ActivitiesPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubActivityApi();
|
var api = new StubActivityApi();
|
||||||
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
|
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.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
|
||||||
await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken);
|
await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken);
|
||||||
|
await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, 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/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/activity/brush-pro/users", api.Paths[1]);
|
||||||
|
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -25,7 +28,8 @@ public class ActivitiesPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubActivityApi();
|
var api = new StubActivityApi();
|
||||||
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
|
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();
|
await vm.RefreshAsync();
|
||||||
|
|
||||||
|
|
@ -76,6 +80,10 @@ public class ActivitiesPageViewModelTests
|
||||||
Name = "Brush",
|
Name = "Brush",
|
||||||
Description = "Coiffure à domicile",
|
Description = "Coiffure à domicile",
|
||||||
PerformerCount = 1,
|
PerformerCount = 1,
|
||||||
|
Forms = new List<CommandFormSummaryDto>
|
||||||
|
{
|
||||||
|
new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }
|
||||||
|
},
|
||||||
Children = new List<ActivityBrowseItemDto>
|
Children = new List<ActivityBrowseItemDto>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
|
|
@ -85,6 +93,10 @@ public class ActivitiesPageViewModelTests
|
||||||
Description = "Spécialisation premium",
|
Description = "Spécialisation premium",
|
||||||
ParentCode = "brush",
|
ParentCode = "brush",
|
||||||
PerformerCount = 1,
|
PerformerCount = 1,
|
||||||
|
Forms = new List<CommandFormSummaryDto>
|
||||||
|
{
|
||||||
|
new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs
Normal file
82
src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
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 = "brush", Name = "Brush" },
|
||||||
|
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
|
||||||
|
new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" },
|
||||||
|
client);
|
||||||
|
|
||||||
|
await vm.SubmitCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Null(api.LastPath);
|
||||||
|
Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingApi : IYavscApiClient
|
||||||
|
{
|
||||||
|
public HttpClient Http { get; } = new();
|
||||||
|
public string? LastPath { get; private set; }
|
||||||
|
public object? LastBody { get; private set; }
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
LastPath = path;
|
||||||
|
LastBody = body;
|
||||||
|
return Task.FromResult(default(T)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
LastPath = path;
|
||||||
|
LastBody = body;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ public static class ServiceCollectionHelpers
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
||||||
|
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
// Vues
|
// Vues
|
||||||
|
|
@ -47,6 +48,8 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton<SignaturePage>();
|
services.AddSingleton<SignaturePage>();
|
||||||
services.AddSingleton<CirclesPage>();
|
services.AddSingleton<CirclesPage>();
|
||||||
services.AddSingleton<ActivitiesPage>();
|
services.AddSingleton<ActivitiesPage>();
|
||||||
|
services.AddTransient<CommandFormsPage>();
|
||||||
|
services.AddTransient<BillingCommandPage>();
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
services.AddSingleton<YavscApiClient>(api);
|
services.AddSingleton<YavscApiClient>(api);
|
||||||
|
|
@ -55,6 +58,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(blogAclClient);
|
services.AddSingleton(blogAclClient);
|
||||||
services.AddSingleton(userSearchClient);
|
services.AddSingleton(userSearchClient);
|
||||||
services.AddSingleton(activityClient);
|
services.AddSingleton(activityClient);
|
||||||
|
services.AddSingleton(billingClient);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
services.AddSingleton<SignaturePageViewModel>();
|
services.AddSingleton<SignaturePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ public class ViewLocator : IDataTemplate
|
||||||
Settings => services.GetRequiredService<SettingsPage>(),
|
Settings => services.GetRequiredService<SettingsPage>(),
|
||||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||||
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
||||||
|
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
|
||||||
|
BillingCommandPageViewModel => services.GetRequiredService<BillingCommandPage>(),
|
||||||
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
|
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
|
||||||
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
|
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
|
||||||
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
|
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.Helpers;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
|
@ -14,6 +16,7 @@ namespace PostIt.ViewModels;
|
||||||
public partial class ActivitiesPageViewModel : ViewModelBase
|
public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ActivityApiClient _client;
|
private readonly ActivityApiClient _client;
|
||||||
|
private readonly BillingApiClient _billingClient;
|
||||||
private bool _syncingSelection;
|
private bool _syncingSelection;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|
@ -31,6 +34,9 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
|
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenCommandFormsCommand))]
|
||||||
|
public partial ActivityUserDisplayItem? SelectedPerformer { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
|
@ -40,6 +46,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
|
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
|
||||||
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
|
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
|
||||||
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
|
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
|
||||||
|
public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0;
|
||||||
|
|
||||||
public override bool CanNavigateNext
|
public override bool CanNavigateNext
|
||||||
{
|
{
|
||||||
|
|
@ -53,9 +60,10 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public ActivitiesPageViewModel(ActivityApiClient client)
|
public ActivitiesPageViewModel(ActivityApiClient client, BillingApiClient billingClient)
|
||||||
{
|
{
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
|
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
|
||||||
|
|
@ -154,11 +162,13 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
OnPropertyChanged(nameof(CurrentActivity));
|
OnPropertyChanged(nameof(CurrentActivity));
|
||||||
OnPropertyChanged(nameof(SelectedActivityLabel));
|
OnPropertyChanged(nameof(SelectedActivityLabel));
|
||||||
OnPropertyChanged(nameof(CurrentActivityLabel));
|
OnPropertyChanged(nameof(CurrentActivityLabel));
|
||||||
|
OnPropertyChanged(nameof(CurrentFormCount));
|
||||||
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
|
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
|
||||||
|
|
||||||
if (activity is null)
|
if (activity is null)
|
||||||
{
|
{
|
||||||
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
||||||
|
SelectedPerformer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +189,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
|
|
||||||
OnPropertyChanged(nameof(CurrentActivity));
|
OnPropertyChanged(nameof(CurrentActivity));
|
||||||
OnPropertyChanged(nameof(CurrentActivityLabel));
|
OnPropertyChanged(nameof(CurrentActivityLabel));
|
||||||
|
OnPropertyChanged(nameof(CurrentFormCount));
|
||||||
|
|
||||||
if (specialization is null)
|
if (specialization is null)
|
||||||
{
|
{
|
||||||
|
|
@ -200,21 +211,47 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
var list = await _client.GetUsersAsync(activity.Code);
|
var list = await _client.GetUsersAsync(activity.Code);
|
||||||
Performers = new ObservableCollection<ActivityUserDisplayItem>((list ?? new())
|
Performers = new ObservableCollection<ActivityUserDisplayItem>((list ?? new())
|
||||||
.Select(ActivityUserDisplayItem.FromDto));
|
.Select(ActivityUserDisplayItem.FromDto));
|
||||||
|
SelectedPerformer = null;
|
||||||
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
|
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
{
|
{
|
||||||
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
||||||
|
SelectedPerformer = null;
|
||||||
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
Performers = new ObservableCollection<ActivityUserDisplayItem>();
|
||||||
|
SelectedPerformer = null;
|
||||||
StatusMessage = $"Erreur: {ex.Message}";
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsBusy = false;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs
Normal file
183
src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
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.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;
|
||||||
|
|
||||||
|
public string Title => Form.Title;
|
||||||
|
public string PerformerLabel => Performer.UserName;
|
||||||
|
public string ActivityLabel => Activity.Name;
|
||||||
|
public bool IsSupported => string.Equals(Form.ActionName, BillingCodes.Rdv, StringComparison.Ordinal);
|
||||||
|
public string BillingRoute => $"/billing/{Form.ActionName}";
|
||||||
|
public string SupportMessage => IsSupported
|
||||||
|
? "Complétez les informations du rendez-vous 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 (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
|
||||||
|
{
|
||||||
|
await _billingClient.CreateAsync(Form.ActionName, new
|
||||||
|
{
|
||||||
|
ActivityCode = Activity.Code,
|
||||||
|
PerformerId = Performer.PerformerId,
|
||||||
|
Consent,
|
||||||
|
EventDate = eventDate,
|
||||||
|
Location = new Location
|
||||||
|
{
|
||||||
|
Address = Address.Trim(),
|
||||||
|
Latitude = latitude,
|
||||||
|
Longitude = longitude,
|
||||||
|
},
|
||||||
|
Reason = Reason.Trim(),
|
||||||
|
Status = QueryStatus.Inserted,
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
StatusMessage = $"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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs
Normal file
82
src/PostIt/PostIt/ViewModels/CommandFormsPageViewModel.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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<CommandFormSummaryDto> Forms { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand))]
|
||||||
|
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<CommandFormSummaryDto>((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;
|
||||||
|
|
||||||
|
[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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.PushPageAsync(new BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,12 +65,14 @@
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*">
|
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*,Auto">
|
||||||
<TextBlock Grid.Row="0" Text="Utilisateurs" FontWeight="Bold" Margin="0,0,0,8" />
|
<TextBlock Grid.Row="0" Text="Utilisateurs" FontWeight="Bold" Margin="0,0,0,8" />
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
|
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
|
||||||
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
|
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
|
||||||
<ListBox Grid.Row="2" ItemsSource="{Binding Performers}">
|
<ListBox Grid.Row="2"
|
||||||
|
ItemsSource="{Binding Performers}"
|
||||||
|
SelectedItem="{Binding SelectedPerformer, Mode=TwoWay}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
|
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
|
||||||
<StackPanel Spacing="2" Margin="0,0,0,8">
|
<StackPanel Spacing="2" Margin="0,0,0,8">
|
||||||
|
|
@ -104,6 +106,10 @@
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
<Button Grid.Row="3"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
Content="Voir les formulaires"
|
||||||
|
Command="{Binding OpenCommandFormsCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
|
||||||
68
src/PostIt/PostIt/Views/BillingCommandPage.axaml
Normal file
68
src/PostIt/PostIt/Views/BillingCommandPage.axaml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
x:Class="PostIt.Views.BillingCommandPage"
|
||||||
|
x:DataType="vm:BillingCommandPageViewModel"
|
||||||
|
Header="Commande billing">
|
||||||
|
<ScrollViewer>
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
|
||||||
|
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="Bold" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
|
||||||
|
Margin="0,4,0,12"
|
||||||
|
Text="{Binding SupportMessage}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Opacity="0.8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding EventDateText, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="5" Text="Motif" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Reason, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="7" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="7" Grid.Column="1" Text="{Binding LatitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="8" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
|
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding LongitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<CheckBox Grid.Row="9" Grid.ColumnSpan="2"
|
||||||
|
Content="Je consens à la création de cette commande"
|
||||||
|
IsChecked="{Binding Consent, Mode=TwoWay}"
|
||||||
|
Margin="0,4,0,12" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="10" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Content="Poster la commande"
|
||||||
|
Command="{Binding SubmitCommand}"
|
||||||
|
IsEnabled="{Binding IsSupported}" />
|
||||||
|
<TextBox Grid.Column="2"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
BorderThickness="0"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<ProgressBar Grid.Column="3"
|
||||||
|
Width="120"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsBusy}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
17
src/PostIt/PostIt/Views/BillingCommandPage.axaml.cs
Normal file
17
src/PostIt/PostIt/Views/BillingCommandPage.axaml.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class BillingCommandPage : ContentPage
|
||||||
|
{
|
||||||
|
public BillingCommandPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/PostIt/PostIt/Views/CommandFormsPage.axaml
Normal file
50
src/PostIt/PostIt/Views/CommandFormsPage.axaml
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:wf="using:Yavsc.Abstract.Workflow"
|
||||||
|
x:Class="PostIt.Views.CommandFormsPage"
|
||||||
|
x:DataType="vm:CommandFormsPageViewModel"
|
||||||
|
Header="Formulaires">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="Bold" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Margin="0,4,0,12"
|
||||||
|
Text="{Binding ContextLabel}"
|
||||||
|
Opacity="0.75" />
|
||||||
|
|
||||||
|
<ListBox Grid.Row="2"
|
||||||
|
ItemsSource="{Binding Forms}"
|
||||||
|
SelectedItem="{Binding SelectedForm, Mode=TwoWay}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="wf:CommandFormSummaryDto">
|
||||||
|
<StackPanel Spacing="2" Margin="0,0,0,10">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="{Binding ActionName, StringFormat='Route billing : /billing/{0}'}"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.7" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,12,*,Auto">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Content="Ouvrir le formulaire"
|
||||||
|
Command="{Binding OpenSelectedFormCommand}" />
|
||||||
|
<TextBox Grid.Column="2"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
BorderThickness="0"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
17
src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs
Normal file
17
src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class CommandFormsPage : ContentPage
|
||||||
|
{
|
||||||
|
public CommandFormsPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,9 @@ namespace Yavsc.Api.Client;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP client for browsing business activities and their performers.
|
/// HTTP client for browsing business activities and their performers.
|
||||||
/// Uses absolute URLs so it can coexist with other Yavsc clients that
|
/// Uses absolute URLs so it can coexist with other Yavsc clients that
|
||||||
/// target a different API host on the same shared transport.
|
/// target a different API host on the same shared transport. The same
|
||||||
|
/// activity payload also carries the eligible billing forms for a
|
||||||
|
/// selected performer/activity pair.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ActivityApiClient
|
public sealed class ActivityApiClient
|
||||||
{
|
{
|
||||||
|
|
|
||||||
42
src/Yavsc.Api.Client/BillingApiClient.cs
Normal file
42
src/Yavsc.Api.Client/BillingApiClient.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for posting commands to the business billing routes.
|
||||||
|
/// Uses absolute URLs so it can coexist with blog-targeting clients on
|
||||||
|
/// the same shared transport.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BillingApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = "billing";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Uri _baseAddress;
|
||||||
|
|
||||||
|
public BillingApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
if (string.IsNullOrWhiteSpace(businessBaseAddress))
|
||||||
|
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
|
||||||
|
|
||||||
|
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CreateAsync(string billingCode, object payload, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("Billing code is required.", nameof(billingCode));
|
||||||
|
|
||||||
|
return _api.CallAsync(
|
||||||
|
HttpMethod.Post,
|
||||||
|
Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}"),
|
||||||
|
body: payload,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,30 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ResetAndSeedRdvQueryGraph()
|
||||||
|
{
|
||||||
|
ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
|
||||||
|
|
||||||
|
db.RdvQueries.Add(new RdvQuery
|
||||||
|
{
|
||||||
|
ActivityCode = "dev",
|
||||||
|
ClientId = "alice",
|
||||||
|
PerformerId = "alice",
|
||||||
|
Consent = true,
|
||||||
|
EventDate = DateTime.UtcNow.AddDays(1),
|
||||||
|
Location = location,
|
||||||
|
Reason = "Initial rendez-vous",
|
||||||
|
Status = Yavsc.QueryStatus.Inserted,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
85
src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
Normal file
85
src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Yavsc;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly ApiWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public RdvQueryApiControllerTests(ApiWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject = "alice", string scope = "api")
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.BaseAddress)
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Billing_rdv_route_supports_crud()
|
||||||
|
{
|
||||||
|
_fixture.ResetAndSeedRdvQueryGraph();
|
||||||
|
using var http = NewClient();
|
||||||
|
|
||||||
|
var createPayload = new RdvQuery
|
||||||
|
{
|
||||||
|
ActivityCode = "dev",
|
||||||
|
PerformerId = "alice",
|
||||||
|
Consent = true,
|
||||||
|
EventDate = DateTime.UtcNow.AddDays(2),
|
||||||
|
Location = new Yavsc.Models.Relationship.Location
|
||||||
|
{
|
||||||
|
Address = "1 rue du Test",
|
||||||
|
Latitude = 48.8566,
|
||||||
|
Longitude = 2.3522,
|
||||||
|
},
|
||||||
|
Reason = "Second rendez-vous",
|
||||||
|
Status = QueryStatus.Inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||||
|
|
||||||
|
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(created);
|
||||||
|
Assert.NotEqual(0, created!.Id);
|
||||||
|
Assert.Equal("alice", created.ClientId);
|
||||||
|
|
||||||
|
var getResponse = await http.GetAsync($"/api/v1/billing/Rdv/{created.Id}", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||||
|
|
||||||
|
var fetched = await getResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(fetched);
|
||||||
|
Assert.Equal(created.Id, fetched!.Id);
|
||||||
|
Assert.Equal("Second rendez-vous", fetched.Reason);
|
||||||
|
|
||||||
|
fetched.Reason = "Rendez-vous modifié";
|
||||||
|
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Rdv/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
|
||||||
|
|
||||||
|
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||||
|
|
||||||
|
var missingResponse = await http.GetAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs
Normal file
190
src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route(Constants.APIPrefix + "/billing/" + BillingCodes.Rdv)]
|
||||||
|
public class RdvQueryApiController : Controller
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public RdvQueryApiController(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetQueries(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
var queries = await _context.RdvQueries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(q => q.Location)
|
||||||
|
.Include(q => q.Client)
|
||||||
|
.Include(q => q.PerformerProfile)
|
||||||
|
.Where(q => q.ClientId == uid || q.PerformerId == uid)
|
||||||
|
.OrderByDescending(q => q.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}", Name = "GetRdvQuery")]
|
||||||
|
public async Task<IActionResult> GetQuery([FromRoute] long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
var query = await _context.RdvQueries
|
||||||
|
.Include(q => q.Location)
|
||||||
|
.Include(q => q.Client)
|
||||||
|
.Include(q => q.PerformerProfile)
|
||||||
|
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
if (query is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(query.ClientId))
|
||||||
|
{
|
||||||
|
query.ClientId = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelState.MarkFieldSkipped("ClientId");
|
||||||
|
|
||||||
|
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("ClientId", "You can only create your own RdvQuery");
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Location is not null)
|
||||||
|
{
|
||||||
|
var existingLocation = await _context.Locations.FirstOrDefaultAsync(
|
||||||
|
x => x.Address == query.Location.Address
|
||||||
|
&& x.Longitude == query.Location.Longitude
|
||||||
|
&& x.Latitude == query.Location.Latitude,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (existingLocation is not null)
|
||||||
|
{
|
||||||
|
query.Location = existingLocation;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_context.Attach(query.Location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.RdvQueries.Add(query);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
if (QueryExists(query.Id))
|
||||||
|
{
|
||||||
|
return Conflict();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> PutQuery([FromRoute] long id, [FromBody] RdvQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id != query.Id)
|
||||||
|
{
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.Entry(query).State = EntityState.Modified;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
if (!QueryExists(id))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
var query = await _context.RdvQueries
|
||||||
|
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
if (query is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.RdvQueries.Remove(query);
|
||||||
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
||||||
|
|
||||||
|
return Ok(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool QueryExists(long id)
|
||||||
|
{
|
||||||
|
return _context.RdvQueries.Any(e => e.Id == id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,8 @@ namespace Yavsc.Models.Workflow
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
string type = ResourcesHelpers.GlobalLocalizer[this.GetType().Name];
|
var localizer = ResourcesHelpers.GlobalLocalizer;
|
||||||
|
string type = localizer is null ? this.GetType().Name : localizer[this.GetType().Name];
|
||||||
return $"{_description} {type}";
|
return $"{_description} {type}";
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue