display my queries

This commit is contained in:
Paul Schneider 2026-08-31 04:27:39 +01:00
commit f4271b4052
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
11 changed files with 691 additions and 43 deletions

View file

@ -4,10 +4,17 @@
### Added ### Added
* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante.
* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress).
### Changed ### Changed
* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour.
### Fixed ### Fixed
* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail.
## [1.0.8-rc9] - unstable ## [1.0.8-rc9] - unstable
### Added ### Added

View file

@ -132,15 +132,71 @@ public class BillingCommandPageViewModelTests
Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32()); Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32());
} }
[Fact]
public async Task InitializeAsync_with_existing_brush_query_prefills_and_submit_updates_query()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 30, Title = "Femme · Cheveux longs", Details = "Coupe · Brushing" },
new() { Id = 31, Title = "Homme · Cheveux courts", Details = "Coupe" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" },
client);
await vm.InitializeAsync(new BillingQueryDetailsDto
{
Id = 77,
BillingCode = "Brush",
ActivityCode = "brush",
PerformerId = "perf-2",
ClientId = "cli-1",
EventDate = new DateTime(2026, 9, 2, 14, 30, 0, DateTimeKind.Utc),
Consent = true,
Status = QueryStatus.Accepted,
PrestationId = 30,
AdditionalInfo = "Ancienne note",
Location = new BillingLocationDto
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
}
});
vm.SelectedPrestation = vm.AvailablePrestations[1];
vm.AdditionalInfo = "Note mise à jour";
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.LastMethod);
Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath);
Assert.True(vm.IsEditingExisting);
Assert.Equal("Mettre à jour la commande", vm.SubmitLabel);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal(77, json.RootElement.GetProperty("Id").GetInt32());
Assert.Equal(31, json.RootElement.GetProperty("PrestationId").GetInt32());
Assert.Equal("Note mise à jour", json.RootElement.GetProperty("AdditionalInfo").GetString());
Assert.Equal((int)QueryStatus.Accepted, json.RootElement.GetProperty("Status").GetInt32());
}
private sealed class RecordingApi : IYavscApiClient private sealed class RecordingApi : IYavscApiClient
{ {
public HttpClient Http { get; } = new(); public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; } public string? LastPath { get; private set; }
public object? LastBody { get; private set; } public object? LastBody { get; private set; }
public List<HairPrestationDto>? HairPrestations { get; init; } public List<HairPrestationDto>? HairPrestations { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
if (typeof(T) == typeof(List<HairPrestationDto>)) if (typeof(T) == typeof(List<HairPrestationDto>))
@ -152,6 +208,7 @@ public class BillingCommandPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
return Task.CompletedTask; return Task.CompletedTask;

View file

@ -22,9 +22,33 @@ public class BillingQueriesPageViewModelTests
await vm.InitializeAsync(); await vm.InitializeAsync();
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single()); Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single());
Assert.Equal(1, vm.Queries.Count); Assert.Equal(3, vm.Queries.Count);
Assert.Equal("Rendez-vous #1", vm.Queries[0].Description); Assert.Contains(vm.Queries, q => q.Description == "Rendez-vous #1");
Assert.Contains("1 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("3 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RefreshAsync_in_readonly_ongoing_mode_keeps_only_ongoing_statuses_and_disables_open()
{
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityBrowseItemDto { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
} }
private sealed class StubBillingApi : IYavscApiClient private sealed class StubBillingApi : IYavscApiClient
@ -70,6 +94,26 @@ public class BillingQueriesPageViewModelTests
Status = QueryStatus.Accepted, Status = QueryStatus.Accepted,
Description = "Autre performer", Description = "Autre performer",
EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc), EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 14,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "En cours",
EventDate = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 15,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Rejected,
Description = "Rejetée",
EventDate = new DateTime(2026, 9, 5, 10, 0, 0, DateTimeKind.Utc),
} }
}; };

View file

@ -61,6 +61,12 @@ public partial class BillingCommandPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty; public partial string AdditionalInfo { get; set; } = string.Empty;
[ObservableProperty]
public partial long? ExistingQueryId { get; set; }
[ObservableProperty]
public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted;
public string Title => Form.Title; public string Title => Form.Title;
public string PerformerLabel => Performer.UserName; public string PerformerLabel => Performer.UserName;
public string ActivityLabel => Activity.Name; public string ActivityLabel => Activity.Name;
@ -73,6 +79,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase
public bool ShowsSinglePrestation => IsBrush; public bool ShowsSinglePrestation => IsBrush;
public bool ShowsMultiplePrestations => IsMultiBrush; public bool ShowsMultiplePrestations => IsMultiBrush;
public string BillingRoute => $"/billing/{Form.ActionName}"; public string BillingRoute => $"/billing/{Form.ActionName}";
public bool IsEditingExisting => ExistingQueryId.HasValue;
public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande";
public string SupportMessage => IsSupported public string SupportMessage => IsSupported
? IsRdv ? IsRdv
? "Complétez les informations du rendez-vous puis postez la commande." ? "Complétez les informations du rendez-vous puis postez la commande."
@ -108,10 +116,21 @@ public partial class BillingCommandPageViewModel : ViewModelBase
StatusMessage = SupportMessage; StatusMessage = SupportMessage;
} }
public async Task InitializeAsync() partial void OnExistingQueryIdChanged(long? value)
{
OnPropertyChanged(nameof(IsEditingExisting));
OnPropertyChanged(nameof(SubmitLabel));
}
public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null)
{ {
if (!IsBrush && !IsMultiBrush) if (!IsBrush && !IsMultiBrush)
{ {
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
}
return; return;
} }
@ -139,6 +158,11 @@ public partial class BillingCommandPageViewModel : ViewModelBase
{ {
IsBusy = false; IsBusy = false;
} }
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
}
} }
[RelayCommand] [RelayCommand]
@ -196,18 +220,44 @@ public partial class BillingCommandPageViewModel : ViewModelBase
Longitude = longitude, Longitude = longitude,
}; };
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = eventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = location.Address,
Latitude = location.Latitude,
Longitude = location.Longitude,
}
};
if (IsRdv) if (IsRdv)
{ {
await _billingClient.CreateAsync(Form.ActionName, new if (IsEditingExisting)
{ {
ActivityCode = Activity.Code, await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
PerformerId = Performer.PerformerId, }
Consent, else
EventDate = eventDate, {
Location = location, await _billingClient.CreateAsync(Form.ActionName, new
Reason = Reason.Trim(), {
Status = QueryStatus.Inserted, ActivityCode = Activity.Code,
}).ConfigureAwait(true); PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = location,
Reason = payload.Reason,
Status = payload.Status,
}).ConfigureAwait(true);
}
} }
else if (IsBrush) else if (IsBrush)
{ {
@ -217,17 +267,26 @@ public partial class BillingCommandPageViewModel : ViewModelBase
return; return;
} }
await _billingClient.CreateAsync(Form.ActionName, new payload.PrestationId = SelectedPrestation.Id;
if (IsEditingExisting)
{ {
ActivityCode = Activity.Code, await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
PerformerId = Performer.PerformerId, }
Consent, else
EventDate = (DateTime?)eventDate, {
Location = location, await _billingClient.CreateAsync(Form.ActionName, new
PrestationId = SelectedPrestation.Id, {
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), ActivityCode = Activity.Code,
Status = QueryStatus.Inserted, PerformerId = Performer.PerformerId,
}).ConfigureAwait(true); Consent,
EventDate = (DateTime?)eventDate,
Location = location,
PrestationId = SelectedPrestation.Id,
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(),
Status = payload.Status,
}).ConfigureAwait(true);
}
} }
else if (IsMultiBrush) else if (IsMultiBrush)
{ {
@ -238,19 +297,30 @@ public partial class BillingCommandPageViewModel : ViewModelBase
return; return;
} }
await _billingClient.CreateAsync(Form.ActionName, new payload.PrestationIds = selectedPrestations.Select(x => x.Id).ToList();
if (IsEditingExisting)
{ {
ActivityCode = Activity.Code, await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
PerformerId = Performer.PerformerId, }
Consent, else
EventDate = eventDate, {
Location = location, await _billingClient.CreateAsync(Form.ActionName, new
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), {
Status = QueryStatus.Inserted, ActivityCode = Activity.Code,
}).ConfigureAwait(true); PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = location,
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(),
Status = payload.Status,
}).ConfigureAwait(true);
}
} }
StatusMessage = $"Commande transmise sur {BillingRoute} pour {Performer.UserName}."; StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -266,6 +336,47 @@ public partial class BillingCommandPageViewModel : ViewModelBase
} }
} }
private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
Reason = existingQuery.Reason ?? string.Empty;
AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty;
if (existingQuery.EventDate is not null)
{
EventDateText = existingQuery.EventDate.Value
.ToLocalTime()
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
if (existingQuery.Location is not null)
{
Address = existingQuery.Location.Address ?? string.Empty;
LatitudeText = existingQuery.Location.Latitude.ToString(CultureInfo.InvariantCulture);
LongitudeText = existingQuery.Location.Longitude.ToString(CultureInfo.InvariantCulture);
}
if (IsBrush && existingQuery.PrestationId is not null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value);
}
if (IsMultiBrush)
{
var selectedIds = existingQuery.PrestationIds is null
? new HashSet<long>()
: new HashSet<long>(existingQuery.PrestationIds);
foreach (var item in MultiPrestations)
{
item.IsSelected = selectedIds.Contains(item.Id);
}
}
StatusMessage = $"Commande #{existingQuery.Id} chargée.";
}
private bool TryParseEventDate(out DateTime eventDate) private bool TryParseEventDate(out DateTime eventDate)
{ {
return DateTime.TryParse( return DateTime.TryParse(

View file

@ -4,8 +4,11 @@ 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;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
@ -18,18 +21,26 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
public ActivityBrowseItemDto Activity { get; } public ActivityBrowseItemDto Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummaryDto Form { get; } public CommandFormSummaryDto Form { get; }
public bool IsReadOnly { get; }
public bool OngoingOnly { get; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<BillingQueryDisplayItem> Queries { get; set; } = new(); public partial ObservableCollection<BillingQueryDisplayItem> Queries { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
public partial BillingQueryDisplayItem? SelectedQuery { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des commandes..."; public partial string StatusMessage { get; set; } = "Chargement des commandes...";
public string Title => $"Commandes {Form.Title}"; public string Title => IsReadOnly
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => !IsReadOnly;
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
@ -47,16 +58,22 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
ActivityBrowseItemDto activity, ActivityBrowseItemDto activity,
ActivityUserDisplayItem performer, ActivityUserDisplayItem performer,
CommandFormSummaryDto form, CommandFormSummaryDto form,
BillingApiClient billingClient) BillingApiClient billingClient,
bool isReadOnly = false,
bool ongoingOnly = false)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
IsReadOnly = isReadOnly;
OngoingOnly = ongoingOnly;
} }
public Task InitializeAsync() => RefreshAsync(); public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => !IsReadOnly && SelectedQuery is not null;
[RelayCommand] [RelayCommand]
public async Task RefreshAsync() public async Task RefreshAsync()
{ {
@ -66,15 +83,14 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
var list = await _billingClient.GetQuerySummariesAsync(Form.ActionName).ConfigureAwait(true); var list = await _billingClient.GetQuerySummariesAsync(Form.ActionName).ConfigureAwait(true);
var filtered = (list ?? new()) var filtered = (list ?? new())
.Where(q => q.ActivityCode == Activity.Code && q.PerformerId == Performer.PerformerId) .Where(q => q.ActivityCode == Activity.Code && q.PerformerId == Performer.PerformerId)
.Where(q => !OngoingOnly || IsOngoingStatus(q.Status))
.OrderByDescending(q => q.EventDate ?? DateTime.MinValue) .OrderByDescending(q => q.EventDate ?? DateTime.MinValue)
.ThenByDescending(q => q.Id) .ThenByDescending(q => q.Id)
.Select(BillingQueryDisplayItem.FromDto) .Select(BillingQueryDisplayItem.FromDto)
.ToList(); .ToList();
Queries = new ObservableCollection<BillingQueryDisplayItem>(filtered); Queries = new ObservableCollection<BillingQueryDisplayItem>(filtered);
StatusMessage = filtered.Count == 0 StatusMessage = BuildLoadedStatusMessage(filtered.Count);
? "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) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -91,4 +107,67 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
IsBusy = false; IsBusy = false;
} }
} }
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (IsReadOnly)
{
StatusMessage = "Mode lecture seule: l'ouverture en modification est désactivée.";
return;
}
if (SelectedQuery is null)
{
StatusMessage = "Sélectionnez une commande.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var vm = new BillingCommandPageViewModel(Activity, Performer, Form, _billingClient);
await vm.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'ouverture: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private string BuildLoadedStatusMessage(int count)
{
if (count == 0)
{
return OngoingOnly
? "Aucune demande en cours pour ce formulaire."
: "Aucune commande trouvée pour ce formulaire.";
}
if (OngoingOnly)
{
return $"{count} demande(s) en cours chargée(s) (lecture seule).";
}
return $"{count} commande(s) chargée(s).";
}
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
} }

View file

@ -21,7 +21,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CommandFormSummaryDto> Forms { get; set; } public partial ObservableCollection<CommandFormSummaryDto> Forms { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand))] [ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))]
public partial CommandFormSummaryDto? SelectedForm { get; set; } public partial CommandFormSummaryDto? SelectedForm { get; set; }
[ObservableProperty] [ObservableProperty]
@ -64,6 +64,8 @@ public partial class CommandFormsPageViewModel : ViewModelBase
private bool CanOpenQueries() => SelectedForm is not null; private bool CanOpenQueries() => SelectedForm is not null;
private bool CanOpenOngoingQueries() => SelectedForm is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedForm))] [RelayCommand(CanExecute = nameof(CanOpenSelectedForm))]
private async Task OpenSelectedFormAsync() private async Task OpenSelectedFormAsync()
{ {
@ -103,4 +105,30 @@ public partial class CommandFormsPageViewModel : ViewModelBase
await vm.InitializeAsync(); await vm.InitializeAsync();
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
[RelayCommand(CanExecute = nameof(CanOpenOngoingQueries))]
private async Task OpenOngoingQueriesAsync()
{
if (SelectedForm is null)
{
StatusMessage = "Sélectionnez un formulaire.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingQueriesPageViewModel(
Activity,
Performer,
SelectedForm,
_billingClient,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
} }

View file

@ -108,7 +108,7 @@
<Grid Grid.Row="13" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto"> <Grid Grid.Row="13" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0" <Button Grid.Column="0"
Content="Poster la commande" Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}" Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" /> IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2" <TextBox Grid.Column="2"

View file

@ -12,9 +12,12 @@
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="0,12,0,12"> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="0,12,0,12">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" /> <Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<Button Content="Ouvrir la commande"
Command="{Binding OpenSelectedQueryCommand}"
IsVisible="{Binding CanOpenDetails}" />
</StackPanel> </StackPanel>
<ListBox Grid.Row="2" ItemsSource="{Binding Queries}"> <ListBox Grid.Row="2" ItemsSource="{Binding Queries}" SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:BillingQueryDisplayItem"> <DataTemplate x:DataType="vm:BillingQueryDisplayItem">
<Border BorderThickness="0,0,0,1" BorderBrush="#22000000" Padding="0,0,0,10" Margin="0,0,0,10"> <Border BorderThickness="0,0,0,1" BorderBrush="#22000000" Padding="0,0,0,10" Margin="0,0,0,10">

View file

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

View file

@ -1,9 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut; using Yavsc.Models.Haircut;
using Yavsc;
namespace Yavsc.Api.Client; namespace Yavsc.Api.Client;
@ -69,5 +72,283 @@ public sealed class BillingApiClient
return items; return items;
} }
public async Task<BillingQueryDetailsDto> GetQueryAsync(string billingCode, long queryId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
var code = billingCode.Trim();
var path = Absolute($"{PathPrefix}/{Uri.EscapeDataString(code)}/{queryId}");
if (string.Equals(code, BillingCodes.Rdv, StringComparison.Ordinal))
{
var dto = await _api.CallAsync<RdvQueryResponse>(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
return MapRdv(dto, code);
}
if (string.Equals(code, BillingCodes.Brush, StringComparison.Ordinal))
{
var dto = await _api.CallAsync<HairCutQueryResponse>(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
return MapBrush(dto, code);
}
if (string.Equals(code, BillingCodes.MBrush, StringComparison.Ordinal))
{
var dto = await _api.CallAsync<HairMultiCutQueryResponse>(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
return MapMBrush(dto, code);
}
throw new NotSupportedException($"Billing code '{code}' is not supported.");
}
public Task UpdateAsync(string billingCode, long queryId, BillingQueryDetailsDto payload, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
if (payload is null)
throw new ArgumentNullException(nameof(payload));
var code = billingCode.Trim();
return _api.CallAsync(
HttpMethod.Put,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(code)}/{queryId}"),
body: BuildUpdatePayload(code, queryId, payload),
ct: ct);
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString(); private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
private static BillingQueryDetailsDto MapRdv(RdvQueryResponse dto, string billingCode)
{
return new BillingQueryDetailsDto
{
Id = dto.Id,
BillingCode = billingCode,
ActivityCode = dto.ActivityCode ?? string.Empty,
PerformerId = dto.PerformerId ?? string.Empty,
ClientId = dto.ClientId ?? string.Empty,
Description = dto.Description ?? string.Empty,
Consent = dto.Consent,
EventDate = dto.EventDate,
Status = dto.Status,
Reason = dto.Reason ?? string.Empty,
Provisional = dto.Provisional,
Location = dto.Location is null
? null
: new BillingLocationDto
{
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
}
};
}
private static BillingQueryDetailsDto MapBrush(HairCutQueryResponse dto, string billingCode)
{
return new BillingQueryDetailsDto
{
Id = dto.Id,
BillingCode = billingCode,
ActivityCode = dto.ActivityCode ?? string.Empty,
PerformerId = dto.PerformerId ?? string.Empty,
ClientId = dto.ClientId ?? string.Empty,
Description = dto.Description ?? string.Empty,
Consent = dto.Consent,
EventDate = dto.EventDate,
Status = dto.Status,
AdditionalInfo = dto.AdditionalInfo ?? string.Empty,
Provisional = dto.Provisional,
PrestationId = dto.PrestationId,
Location = dto.Location is null
? null
: new BillingLocationDto
{
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
}
};
}
private static BillingQueryDetailsDto MapMBrush(HairMultiCutQueryResponse dto, string billingCode)
{
return new BillingQueryDetailsDto
{
Id = dto.Id,
BillingCode = billingCode,
ActivityCode = dto.ActivityCode ?? string.Empty,
PerformerId = dto.PerformerId ?? string.Empty,
ClientId = dto.ClientId ?? string.Empty,
Description = dto.Description ?? string.Empty,
Consent = dto.Consent,
EventDate = dto.EventDate,
Status = dto.Status,
Provisional = dto.Provisional,
PrestationIds = (dto.Prestations ?? new List<HairPrestationCollectionItemResponse>())
.Select(p => p.PrestationId)
.Where(id => id > 0)
.ToList(),
Location = dto.Location is null
? null
: new BillingLocationDto
{
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
}
};
}
private static object BuildUpdatePayload(string billingCode, long queryId, BillingQueryDetailsDto payload)
{
if (string.Equals(billingCode, BillingCodes.Rdv, StringComparison.Ordinal))
{
if (payload.EventDate is null)
throw new ArgumentException("EventDate is required for Rdv.", nameof(payload));
return new
{
Id = queryId,
ActivityCode = payload.ActivityCode,
PerformerId = payload.PerformerId,
ClientId = payload.ClientId,
Consent = payload.Consent,
EventDate = payload.EventDate.Value,
Location = ToLocationPayload(payload.Location),
Reason = payload.Reason,
Status = payload.Status,
Provisional = payload.Provisional,
Description = payload.Description,
};
}
if (string.Equals(billingCode, BillingCodes.Brush, StringComparison.Ordinal))
{
if (payload.PrestationId is null || payload.PrestationId <= 0)
throw new ArgumentException("PrestationId is required for Brush.", nameof(payload));
return new
{
Id = queryId,
ActivityCode = payload.ActivityCode,
PerformerId = payload.PerformerId,
ClientId = payload.ClientId,
Consent = payload.Consent,
EventDate = payload.EventDate,
Location = ToLocationPayload(payload.Location),
PrestationId = payload.PrestationId.Value,
AdditionalInfo = payload.AdditionalInfo,
Status = payload.Status,
Provisional = payload.Provisional,
Description = payload.Description,
};
}
if (string.Equals(billingCode, BillingCodes.MBrush, StringComparison.Ordinal))
{
if (payload.EventDate is null)
throw new ArgumentException("EventDate is required for MBrush.", nameof(payload));
if (payload.PrestationIds is null || payload.PrestationIds.Count == 0)
throw new ArgumentException("At least one prestation is required for MBrush.", nameof(payload));
return new
{
Id = queryId,
ActivityCode = payload.ActivityCode,
PerformerId = payload.PerformerId,
ClientId = payload.ClientId,
Consent = payload.Consent,
EventDate = payload.EventDate.Value,
Location = ToLocationPayload(payload.Location),
Prestations = payload.PrestationIds
.Where(id => id > 0)
.Select(id => new { PrestationId = id })
.ToList(),
Status = payload.Status,
Provisional = payload.Provisional,
Description = payload.Description,
};
}
throw new NotSupportedException($"Billing code '{billingCode}' is not supported.");
}
private static object? ToLocationPayload(BillingLocationDto? location)
{
if (location is null)
{
return null;
}
return new
{
Address = location.Address,
Latitude = location.Latitude,
Longitude = location.Longitude,
};
}
private sealed class BillingLocationResponse
{
public string? Address { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
}
private sealed class RdvQueryResponse
{
public long Id { get; set; }
public string? ActivityCode { get; set; }
public string? PerformerId { get; set; }
public string? ClientId { get; set; }
public string? Description { get; set; }
public bool Consent { get; set; }
public DateTime EventDate { get; set; }
public QueryStatus Status { get; set; }
public string? Reason { get; set; }
public decimal? Provisional { get; set; }
public BillingLocationResponse? Location { get; set; }
}
private sealed class HairCutQueryResponse
{
public long Id { get; set; }
public string? ActivityCode { get; set; }
public string? PerformerId { get; set; }
public string? ClientId { get; set; }
public string? Description { get; set; }
public bool Consent { get; set; }
public DateTime? EventDate { get; set; }
public QueryStatus Status { get; set; }
public decimal? Provisional { get; set; }
public long PrestationId { get; set; }
public string? AdditionalInfo { get; set; }
public BillingLocationResponse? Location { get; set; }
}
private sealed class HairMultiCutQueryResponse
{
public long Id { get; set; }
public string? ActivityCode { get; set; }
public string? PerformerId { get; set; }
public string? ClientId { get; set; }
public string? Description { get; set; }
public bool Consent { get; set; }
public DateTime EventDate { get; set; }
public QueryStatus Status { get; set; }
public decimal? Provisional { get; set; }
public BillingLocationResponse? Location { get; set; }
public List<HairPrestationCollectionItemResponse>? Prestations { get; set; }
}
private sealed class HairPrestationCollectionItemResponse
{
public long PrestationId { get; set; }
}
} }

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using Yavsc;
namespace Yavsc.Api.Client;
/// <summary>
/// Normalized billing-query shape used by PostIt when opening an existing
/// command from history.
/// </summary>
public sealed class BillingQueryDetailsDto
{
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 string Description { get; set; } = string.Empty;
public bool Consent { get; set; } = true;
public DateTime? EventDate { get; set; }
public QueryStatus Status { get; set; } = QueryStatus.Inserted;
public string Reason { get; set; } = string.Empty;
public string AdditionalInfo { get; set; } = string.Empty;
public decimal? Provisional { get; set; }
public BillingLocationDto? Location { get; set; }
public long? PrestationId { get; set; }
public List<long> PrestationIds { get; set; } = new();
}
public sealed class BillingLocationDto
{
public string Address { get; set; } = string.Empty;
public double Latitude { get; set; }
public double Longitude { get; set; }
}