hitting the performer

This commit is contained in:
Paul Schneider 2026-08-31 04:15:10 +01:00
commit 62a6236865
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
27 changed files with 1603 additions and 35 deletions

View file

@ -17,10 +17,12 @@ public class ActivitiesPageViewModelTests
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]

View file

@ -4,6 +4,7 @@ using PostIt.ViewModels;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Haircut;
namespace PostIt.Tests;
@ -46,9 +47,9 @@ public class BillingCommandPageViewModelTests
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 ActivityBrowseItemDto { Code = "book", Name = "Book" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" },
new CommandFormSummaryDto { Id = 13, ActionName = "Book", Title = "Réservation" },
client);
await vm.SubmitCommand.ExecuteAsync(null);
@ -57,16 +58,95 @@ public class BillingCommandPageViewModelTests
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<HairPrestationDto>
{
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<HairPrestationDto>
{
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());
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public List<HairPrestationDto>? HairPrestations { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastPath = path;
LastBody = body;
if (typeof(T) == typeof(List<HairPrestationDto>))
{
return Task.FromResult((T)(object)(HairPrestations ?? new List<HairPrestationDto>()));
}
return Task.FromResult(default(T)!);
}

View file

@ -0,0 +1,90 @@
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(1, vm.Queries.Count);
Assert.Equal("Rendez-vous #1", vm.Queries[0].Description);
Assert.Contains("1 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
private sealed class StubBillingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
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),
}
};
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;
}
}

View file

@ -50,6 +50,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<BillingCommandPage>();
services.AddTransient<BillingQueriesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -64,6 +65,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
services.AddTransient<SelectableHairPrestationItem>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or

View file

@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
BillingCommandPageViewModel => services.GetRequiredService<BillingCommandPage>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),

View file

@ -1,7 +1,10 @@
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;
@ -9,6 +12,7 @@ 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;
@ -45,13 +49,36 @@ public partial class BillingCommandPageViewModel : ViewModelBase
[ObservableProperty]
public partial bool Consent { get; set; } = true;
[ObservableProperty]
public partial ObservableCollection<HairPrestationDto> AvailablePrestations { get; set; } = new();
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
[ObservableProperty]
public partial ObservableCollection<SelectableHairPrestationItem> MultiPrestations { get; set; } = new();
[ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty;
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 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 string SupportMessage => IsSupported
? "Complétez les informations du rendez-vous puis postez la commande."
? 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
@ -81,6 +108,39 @@ public partial class BillingCommandPageViewModel : ViewModelBase
StatusMessage = SupportMessage;
}
public async Task InitializeAsync()
{
if (!IsBrush && !IsMultiBrush)
{
return;
}
IsBusy = true;
try
{
var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName).ConfigureAwait(true);
AvailablePrestations = new ObservableCollection<HairPrestationDto>(prestations ?? new List<HairPrestationDto>());
SelectedPrestation = AvailablePrestations.FirstOrDefault();
MultiPrestations = new ObservableCollection<SelectableHairPrestationItem>(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;
}
}
[RelayCommand]
private async Task SubmitAsync()
{
@ -102,7 +162,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase
return;
}
if (string.IsNullOrWhiteSpace(Reason))
if (IsRdv && string.IsNullOrWhiteSpace(Reason))
{
StatusMessage = "Le motif du rendez-vous est requis.";
return;
@ -129,21 +189,66 @@ public partial class BillingCommandPageViewModel : ViewModelBase
IsBusy = true;
try
{
await _billingClient.CreateAsync(Form.ActionName, new
var location = new Location
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = new Location
Address = Address.Trim(),
Latitude = latitude,
Longitude = longitude,
};
if (IsRdv)
{
await _billingClient.CreateAsync(Form.ActionName, new
{
Address = Address.Trim(),
Latitude = latitude,
Longitude = longitude,
},
Reason = Reason.Trim(),
Status = QueryStatus.Inserted,
}).ConfigureAwait(true);
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = location,
Reason = Reason.Trim(),
Status = QueryStatus.Inserted,
}).ConfigureAwait(true);
}
else if (IsBrush)
{
if (SelectedPrestation is null)
{
StatusMessage = "Sélectionnez une prestation coiffure.";
return;
}
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 = QueryStatus.Inserted,
}).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;
}
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 = QueryStatus.Inserted,
}).ConfigureAwait(true);
}
StatusMessage = $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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; }
[ObservableProperty]
public partial ObservableCollection<BillingQueryDisplayItem> Queries { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des commandes...";
public string Title => $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
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)
{
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));
}
public Task InitializeAsync() => RefreshAsync();
[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)
.OrderByDescending(q => q.EventDate ?? DateTime.MinValue)
.ThenByDescending(q => q.Id)
.Select(BillingQueryDisplayItem.FromDto)
.ToList();
Queries = new ObservableCollection<BillingQueryDisplayItem>(filtered);
StatusMessage = filtered.Count == 0
? "Aucune commande trouvée pour ce formulaire."
: $"{filtered.Count} commande(s) chargée(s).";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -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,
};
}
}

View file

@ -21,7 +21,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase
[ObservableProperty]
public partial ObservableCollection<CommandFormSummaryDto> Forms { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand))]
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand))]
public partial CommandFormSummaryDto? SelectedForm { get; set; }
[ObservableProperty]
@ -62,6 +62,8 @@ public partial class CommandFormsPageViewModel : ViewModelBase
private bool CanOpenSelectedForm() => SelectedForm is not null;
private bool CanOpenQueries() => SelectedForm is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedForm))]
private async Task OpenSelectedFormAsync()
{
@ -77,6 +79,28 @@ public partial class CommandFormsPageViewModel : ViewModelBase
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.PushPageAsync(new BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient));
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);
}
}

View file

@ -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,
};
}

View file

@ -5,7 +5,7 @@
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">
<Grid RowDefinitions="Auto,Auto,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"
@ -26,8 +26,16 @@
<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="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsReason}" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsReason}" />
<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" />
@ -38,12 +46,67 @@
<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"
<TextBlock Grid.Row="9"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsSinglePrestation}" />
<ComboBox Grid.Row="9"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsSinglePrestation}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="10"
Text="Prestations"
VerticalAlignment="Top"
Margin="0,0,12,8"
IsVisible="{Binding ShowsMultiplePrestations}" />
<ListBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding MultiPrestations}"
IsVisible="{Binding ShowsMultiplePrestations}"
MaxHeight="180"
Margin="0,0,0,8">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="11"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<TextBox Grid.Row="11"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<CheckBox Grid.Row="12" 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">
<Grid Grid.Row="13" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="Poster la commande"
Command="{Binding SubmitCommand}"

View file

@ -0,0 +1,49 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.BillingQueriesPage"
x:DataType="vm:BillingQueriesPageViewModel"
Header="Commandes billing">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="0,12,0,12">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
</StackPanel>
<ListBox Grid.Row="2" ItemsSource="{Binding Queries}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:BillingQueryDisplayItem">
<Border BorderThickness="0,0,0,1" BorderBrush="#22000000" Padding="0,0,0,10" Margin="0,0,0,10">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Description}" FontWeight="Bold" />
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" FontSize="12" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding EventDateLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding BillingCode}" FontSize="11" Opacity="0.6" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<TextBox Grid.Column="0"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class BillingQueriesPage : ContentPage
{
public BillingQueriesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -31,11 +31,14 @@
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,12,*,Auto">
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,8,Auto,12,*,Auto">
<Button Grid.Column="0"
Content="Ouvrir le formulaire"
Command="{Binding OpenSelectedFormCommand}" />
<TextBox Grid.Column="2"
<Button Grid.Column="2"
Content="Voir les commandes"
Command="{Binding OpenQueriesCommand}" />
<TextBox Grid.Column="4"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"

View file

@ -0,0 +1,11 @@
namespace Yavsc.Models.Haircut;
/// <summary>
/// Lightweight hair-prestation description exposed to API clients.
/// </summary>
public sealed class HairPrestationDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Models.Haircut;
namespace Yavsc.Api.Client;
@ -38,5 +40,34 @@ public sealed class BillingApiClient
ct: ct);
}
public Task<List<HairPrestationDto>> GetHairPrestationsAsync(string billingCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
return _api.CallAsync<List<HairPrestationDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}/prestations"),
ct: ct);
}
public async Task<List<BillingQuerySummaryDto>> GetQuerySummariesAsync(string billingCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
var items = await _api.CallAsync<List<BillingQuerySummaryDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}"),
ct: ct) ?? new List<BillingQuerySummaryDto>();
foreach (var item in items)
{
item.BillingCode = billingCode;
}
return items;
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
}

View file

@ -0,0 +1,23 @@
using System;
using Yavsc;
namespace Yavsc.Api.Client;
/// <summary>
/// Lightweight billing-query projection consumed by PostIt list views.
/// Extra JSON fields from concrete query types are ignored.
/// </summary>
public sealed class BillingQuerySummaryDto
{
public long Id { get; set; }
public string BillingCode { get; set; } = string.Empty;
public string ActivityCode { get; set; } = string.Empty;
public string PerformerId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public QueryStatus Status { get; set; }
public string Description { get; set; } = string.Empty;
public DateTime? EventDate { get; set; }
public string Reason { get; set; } = string.Empty;
public string AdditionalInfo { get; set; } = string.Empty;
public decimal? Provisional { get; set; }
}

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Controllers;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
@ -212,6 +213,114 @@ public sealed class ApiWebServerFixture : WebHostFixture
db.SaveChanges();
}
public void ResetAndSeedHaircutGraph()
{
ResetAndSeedActivityGraph();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
if (!db.Activities.Any(a => a.Code == "brush"))
{
db.Activities.Add(new Activity
{
Code = "brush",
Name = "Brush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.Activities.Any(a => a.Code == "mbrush"))
{
db.Activities.Add(new Activity
{
Code = "mbrush",
Name = "MBrush",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
}
if (!db.BrusherProfile.Any(p => p.UserId == "alice"))
{
db.BrusherProfile.Add(new BrusherProfile
{
UserId = "alice",
ActionDistance = 25,
WomenLongCutPrice = 50m,
WomenHalfCutPrice = 40m,
WomenShortCutPrice = 30m,
ManCutPrice = 20m,
KidCutPrice = 15m,
ShampooPrice = 5m,
});
}
var prestation1 = new HairPrestation
{
Gender = HairCutGenders.Women,
Length = HairLength.HalfLong,
Cut = true,
Shampoo = true,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
var prestation2 = new HairPrestation
{
Gender = HairCutGenders.Man,
Length = HairLength.Short,
Cut = true,
Shampoo = false,
Dressing = HairDressings.Brushing,
Tech = HairTechnos.NoTech,
Cares = false,
Taints = new List<HairTaintInstance>(),
};
db.HairPrestation.AddRange(prestation1, prestation2);
db.SaveChanges();
db.HairCutQueries.Add(new HairCutQuery
{
ActivityCode = "brush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(3),
Location = location,
PrestationId = prestation1.Id,
Prestation = prestation1,
AdditionalInfo = "Coupe test",
Status = Yavsc.QueryStatus.Inserted,
Description = "Haircut seed",
});
db.HairMultiCutQueries.Add(new HairMultiCutQuery
{
ActivityCode = "mbrush",
ClientId = "alice",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(4),
Location = location,
Prestations = new List<HairPrestationCollectionItem>
{
new() { PrestationId = prestation1.Id, Prestation = prestation1 },
new() { PrestationId = prestation2.Id, Prestation = prestation2 },
},
Status = Yavsc.QueryStatus.Inserted,
});
db.SaveChanges();
}
public override void Dispose()
{
try

View file

@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HairCutQueryApiControllerTests(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_brush_route_supports_crud()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var prestationId = await GetPrestationIdAsync();
var createPayload = new HairCutQuery
{
ActivityCode = "brush",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(5),
Location = new Location
{
Address = "2 rue de la Coupe",
Latitude = 48.8570,
Longitude = 2.3525,
},
PrestationId = prestationId,
AdditionalInfo = "Brushing test",
Status = QueryStatus.Inserted,
Description = "Haircut create",
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Brush", createPayload, TestContext.Current.CancellationToken);
if (createResponse.StatusCode != HttpStatusCode.Created)
{
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
}
var created = await createResponse.Content.ReadFromJsonAsync<HairCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
var getResponse = await http.GetAsync($"/api/v1/billing/Brush/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<HairCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal("Brushing test", fetched.AdditionalInfo);
fetched.AdditionalInfo = "Brushing modifié";
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Brush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var missingResponse = await http.GetAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
}
[Fact]
public async Task Billing_brush_route_exposes_prestation_catalog()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/billing/Brush/prestations", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = await response.Content.ReadFromJsonAsync<List<HairPrestationDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.NotEmpty(catalog!);
Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Title)));
Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
&& item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
}
private async Task<long> GetPrestationIdAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await db.HairPrestation.Select(p => p.Id).FirstAsync(TestContext.Current.CancellationToken);
}
}

View file

@ -0,0 +1,123 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HairMultiCutQueryApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HairMultiCutQueryApiControllerTests(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_mbrush_route_supports_crud()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var prestationIds = await GetPrestationIdsAsync();
var createPayload = new HairMultiCutQuery
{
ActivityCode = "mbrush",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(6),
Location = new Location
{
Address = "3 rue du Groupe",
Latitude = 48.8580,
Longitude = 2.3530,
},
Prestations = prestationIds.Select(id => new HairPrestationCollectionItem { PrestationId = id }).ToList(),
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/MBrush", createPayload, TestContext.Current.CancellationToken);
if (createResponse.StatusCode != HttpStatusCode.Created)
{
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
}
var created = await createResponse.Content.ReadFromJsonAsync<HairMultiCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
Assert.Equal(2, created.Prestations.Count);
var getResponse = await http.GetAsync($"/api/v1/billing/MBrush/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<HairMultiCutQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched!.Id);
Assert.Equal(2, fetched.Prestations.Count);
fetched.Status = QueryStatus.Accepted;
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/MBrush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var missingResponse = await http.GetAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
}
[Fact]
public async Task Billing_mbrush_route_exposes_prestation_catalog()
{
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/billing/MBrush/prestations", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = await response.Content.ReadFromJsonAsync<List<HairPrestationDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.NotEmpty(catalog!);
Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Details)));
Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
&& item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
}
private async Task<List<long>> GetPrestationIdsAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await db.HairPrestation
.OrderBy(p => p.Id)
.Select(p => p.Id)
.Take(2)
.ToListAsync(TestContext.Current.CancellationToken);
}
}

View file

@ -0,0 +1,234 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers;
[Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/billing/" + BillingCodes.Brush)]
public class HairCutQueryApiController : Controller
{
private readonly ApplicationDbContext _context;
public HairCutQueryApiController(ApplicationDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> GetQueries(CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var queries = await _context.HairCutQueries
.AsNoTracking()
.Include(q => q.Prestation)
.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("prestations")]
public async Task<IActionResult> GetPrestations(CancellationToken cancellationToken)
{
var prestations = await _context.HairPrestation
.AsNoTracking()
.OrderBy(p => p.Gender)
.ThenBy(p => p.Length)
.ThenBy(p => p.Tech)
.Select(p => ToDto(p))
.ToListAsync(cancellationToken);
return Ok(prestations);
}
[HttpGet("{id}", Name = "GetBillingHairCutQuery")]
public async Task<IActionResult> GetQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var query = await _context.HairCutQueries
.Include(q => q.Prestation)
.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] HairCutQuery query, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(query.ClientId))
{
query.ClientId = uid;
}
ModelState.Remove("ClientId");
ModelState.Remove("Prestation");
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
ModelState.AddModelError("ClientId", "You can only create your own HairCutQuery");
return BadRequest(ModelState);
}
query.Prestation = await _context.HairPrestation
.SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken);
if (query.Prestation is null)
{
ModelState.AddModelError("PrestationId", "Unknown hair prestation.");
return BadRequest(ModelState);
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
query.Location = await ResolveLocationAsync(query.Location, cancellationToken);
_context.HairCutQueries.Add(query);
try
{
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
}
catch (DbUpdateException)
{
if (QueryExists(query.Id))
{
return Conflict();
}
throw;
}
return CreatedAtRoute("GetBillingHairCutQuery", new { id = query.Id }, query);
}
[HttpPut("{id}")]
public async Task<IActionResult> PutQuery([FromRoute] long id, [FromBody] HairCutQuery query, CancellationToken cancellationToken)
{
var existing = await _context.HairCutQueries
.Include(q => q.Location)
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
if (existing is null)
{
return NotFound();
}
var uid = User.GetUserId();
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
var prestation = await _context.HairPrestation
.SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken);
if (prestation is null)
{
ModelState.AddModelError("PrestationId", "Unknown hair prestation.");
return BadRequest(ModelState);
}
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
existing.EventDate = query.EventDate;
existing.AdditionalInfo = query.AdditionalInfo;
existing.Status = query.Status;
existing.Provisional = query.Provisional;
existing.PrestationId = prestation.Id;
existing.Prestation = prestation;
existing.Location = await ResolveLocationAsync(query.Location, cancellationToken);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var query = await _context.HairCutQueries
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
if (query is null)
{
return NotFound();
}
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
_context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
return Ok(query);
}
private async Task<Location> ResolveLocationAsync(Location candidate, CancellationToken cancellationToken)
{
if (candidate is null)
{
return null;
}
var existingLocation = await _context.Locations.FirstOrDefaultAsync(
x => x.Address == candidate.Address
&& x.Longitude == candidate.Longitude
&& x.Latitude == candidate.Latitude,
cancellationToken);
if (existingLocation is not null)
{
return existingLocation;
}
_context.Attach(candidate);
return candidate;
}
private bool QueryExists(long id)
{
return _context.HairCutQueries.Any(e => e.Id == id);
}
private static HairPrestationDto ToDto(HairPrestation prestation)
{
return new HairPrestationDto
{
Id = prestation.Id,
Title = prestation.GetDisplayTitle(),
Details = prestation.GetDisplayDetails(),
};
}
}

View file

@ -0,0 +1,286 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers;
[Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/billing/" + BillingCodes.MBrush)]
public class HairMultiCutQueryApiController : Controller
{
private readonly ApplicationDbContext _context;
public HairMultiCutQueryApiController(ApplicationDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> GetQueries(CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var queries = await _context.HairMultiCutQueries
.AsNoTracking()
.Include(q => q.Prestations)
.ThenInclude(p => p.Prestation)
.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.Select(SanitizeForResponse).ToList());
}
[HttpGet("prestations")]
public async Task<IActionResult> GetPrestations(CancellationToken cancellationToken)
{
var prestations = await _context.HairPrestation
.AsNoTracking()
.OrderBy(p => p.Gender)
.ThenBy(p => p.Length)
.ThenBy(p => p.Tech)
.Select(p => new HairPrestationDto
{
Id = p.Id,
Title = p.GetDisplayTitle(),
Details = p.GetDisplayDetails()
})
.ToListAsync(cancellationToken);
return Ok(prestations);
}
[HttpGet("{id}", Name = "GetBillingHairMultiCutQuery")]
public async Task<IActionResult> GetQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var query = await _context.HairMultiCutQueries
.Include(q => q.Prestations)
.ThenInclude(p => p.Prestation)
.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(SanitizeForResponse(query));
}
[HttpPost]
public async Task<IActionResult> PostQuery([FromBody] HairMultiCutQuery query, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(query.ClientId))
{
query.ClientId = uid;
}
ModelState.Remove("ClientId");
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
ModelState.AddModelError("ClientId", "You can only create your own HairMultiCutQuery");
return BadRequest(ModelState);
}
if (query.Prestations is null || query.Prestations.Count == 0)
{
ModelState.AddModelError("Prestations", "At least one hair prestation is required.");
return BadRequest(ModelState);
}
var prestationItems = await ResolvePrestationsAsync(query.Prestations, cancellationToken);
if (prestationItems is null)
{
ModelState.AddModelError("Prestations", "One or more hair prestations are unknown.");
return BadRequest(ModelState);
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
query.Prestations = prestationItems;
query.Location = await ResolveLocationAsync(query.Location, cancellationToken);
_context.HairMultiCutQueries.Add(query);
try
{
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
}
catch (DbUpdateException)
{
if (QueryExists(query.Id))
{
return Conflict();
}
throw;
}
return CreatedAtRoute("GetBillingHairMultiCutQuery", new { id = query.Id }, SanitizeForResponse(query));
}
[HttpPut("{id}")]
public async Task<IActionResult> PutQuery([FromRoute] long id, [FromBody] HairMultiCutQuery query, CancellationToken cancellationToken)
{
var existing = await _context.HairMultiCutQueries
.Include(q => q.Prestations)
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
if (existing is null)
{
return NotFound();
}
var uid = User.GetUserId();
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
if (query.Prestations is null || query.Prestations.Count == 0)
{
ModelState.AddModelError("Prestations", "At least one hair prestation is required.");
return BadRequest(ModelState);
}
var prestationItems = await ResolvePrestationsAsync(query.Prestations, cancellationToken);
if (prestationItems is null)
{
ModelState.AddModelError("Prestations", "One or more hair prestations are unknown.");
return BadRequest(ModelState);
}
_context.RemoveRange(existing.Prestations);
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
existing.EventDate = query.EventDate;
existing.Status = query.Status;
existing.Provisional = query.Provisional;
existing.Prestations = prestationItems;
existing.Location = await ResolveLocationAsync(query.Location, cancellationToken);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.GetUserId();
var query = await _context.HairMultiCutQueries
.Include(q => q.Prestations)
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
if (query is null)
{
return NotFound();
}
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
_context.RemoveRange(query.Prestations);
_context.HairMultiCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
return Ok(SanitizeForResponse(query));
}
private async Task<List<HairPrestationCollectionItem>> ResolvePrestationsAsync(
IEnumerable<HairPrestationCollectionItem> requestedItems,
CancellationToken cancellationToken)
{
var ids = requestedItems
.Select(x => x.PrestationId)
.Where(x => x > 0)
.ToArray();
if (ids.Length == 0)
{
return null;
}
var prestations = await _context.HairPrestation
.Where(p => ids.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, cancellationToken);
if (prestations.Count != ids.Distinct().Count())
{
return null;
}
return requestedItems
.Select(item => new HairPrestationCollectionItem
{
PrestationId = item.PrestationId,
Prestation = prestations[item.PrestationId],
})
.ToList();
}
private async Task<Location> ResolveLocationAsync(Location candidate, CancellationToken cancellationToken)
{
if (candidate is null)
{
return null;
}
var existingLocation = await _context.Locations.FirstOrDefaultAsync(
x => x.Address == candidate.Address
&& x.Longitude == candidate.Longitude
&& x.Latitude == candidate.Latitude,
cancellationToken);
if (existingLocation is not null)
{
return existingLocation;
}
_context.Attach(candidate);
return candidate;
}
private bool QueryExists(long id)
{
return _context.HairMultiCutQueries.Any(e => e.Id == id);
}
private static HairMultiCutQuery SanitizeForResponse(HairMultiCutQuery query)
{
if (query.Prestations is not null)
{
foreach (var item in query.Prestations)
{
item.Query = null;
}
}
return query;
}
}

View file

@ -1,4 +1,3 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -24,7 +23,7 @@ public class RdvQueryApiController : Controller
[HttpGet]
public async Task<IActionResult> GetQueries(CancellationToken cancellationToken)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
var queries = await _context.RdvQueries
.AsNoTracking()
@ -41,7 +40,7 @@ public class RdvQueryApiController : Controller
[HttpGet("{id}", Name = "GetRdvQuery")]
public async Task<IActionResult> GetQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
var query = await _context.RdvQueries
.Include(q => q.Location)
@ -65,13 +64,13 @@ public class RdvQueryApiController : Controller
[HttpPost]
public async Task<IActionResult> PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(query.ClientId))
{
query.ClientId = uid;
}
ModelState.MarkFieldSkipped("ClientId");
ModelState.Remove("ClientId");
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
@ -134,7 +133,7 @@ public class RdvQueryApiController : Controller
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
@ -162,7 +161,7 @@ public class RdvQueryApiController : Controller
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
var query = await _context.RdvQueries
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);

View file

@ -4,8 +4,10 @@ namespace Yavsc.Models.Haircut
{
public enum HairDressings {
[Display(Name="Coiffage")]
Coiffage,
[Display(Name="Brushing")]
Brushing,
[Display(Name="Mise en plis")]

View file

@ -1,12 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Models.Haircut
{
public enum HairLength : int
{
[Display(Name="Cheveux mi-longs")]
HalfLong=0,
[Display(Name="Cheveux courts")]
Short=1,
[Display(Name="Cheveux longs")]
Long=2
}
}

View file

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using Newtonsoft.Json;
namespace Yavsc.Models.Haircut
@ -40,6 +41,41 @@ namespace Yavsc.Models.Haircut
[Display(Name="Soins")]
public bool Cares { get; set; }
public string GetDisplayTitle()
{
return $"{GetEnumDisplayName(Gender)} · {GetEnumDisplayName(Length)}";
}
public string GetDisplayDetails()
{
return string.Join(" · ", new[]
{
FormatFlag(nameof(Cut), Cut),
GetEnumDisplayName(Dressing),
GetEnumDisplayName(Tech),
FormatFlag(nameof(Shampoo), Shampoo),
FormatFlag(nameof(Cares), Cares),
});
}
private static string FormatFlag(string propertyName, bool enabled)
{
var label = GetPropertyDisplayName(propertyName);
return enabled ? label : $"Sans {label.ToLowerInvariant()}";
}
private static string GetPropertyDisplayName(string propertyName)
{
var property = typeof(HairPrestation).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
return property?.GetCustomAttribute<DisplayAttribute>()?.GetName() ?? propertyName;
}
private static string GetEnumDisplayName<TEnum>(TEnum value) where TEnum : struct, Enum
{
var member = typeof(TEnum).GetMember(value.ToString()).FirstOrDefault();
return member?.GetCustomAttribute<DisplayAttribute>()?.GetName() ?? value.ToString();
}
}
public class HairTaintInstance {

View file

@ -11,12 +11,14 @@ namespace Yavsc.Models.Haircut
[Display(Name="Couleur")]
Color,
[Display(Name="Permantante")]
[Display(Name="Permanente")]
Permanent,
[Display(Name="Défrisage")]
Defris,
[Display(Name="Mêches")]
Mech,
[Display(Name="Balayage")]
Balayage
}
}