feat/files-control #52

Open
notazof wants to merge 32 commits from feat/files-control into main
19 changed files with 1235 additions and 14 deletions
Showing only changes of commit 848f8be42f - Show all commits

Estimate UI
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m20s

Paul Schneider 2026-09-13 18:54:27 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -95,6 +95,15 @@ showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
showOrgLogs:
@sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
showBlogsLogs:
@sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
clean:
@rm -rf generated

View file

@ -0,0 +1,270 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateEditionPageViewModelTests
{
private static BillingQuerySummaryDto SampleQuery() => new()
{
Id = 42,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
};
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
}
[Fact]
public void Constructor_prefills_description_and_adds_a_first_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
Assert.Equal("Coupe simple", vm.EstimateDescription);
Assert.Single(vm.Lines);
Assert.Same(vm.Lines[0], vm.SelectedLine);
Assert.Contains("#42", vm.ContextLabel);
Assert.Contains("cli-1", vm.ContextLabel);
}
[Fact]
public void AddLine_appends_and_selects_the_new_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.AddLineCommand.Execute(null);
Assert.Equal(2, vm.Lines.Count);
Assert.Same(vm.Lines[1], vm.SelectedLine);
}
[Fact]
public void RemoveLine_removes_the_selected_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
var first = vm.Lines[0];
vm.RemoveLineCommand.Execute(null);
Assert.Empty(vm.Lines);
Assert.Null(vm.SelectedLine);
Assert.False(vm.RemoveLineCommand.CanExecute(null));
Assert.DoesNotContain(first, vm.Lines);
}
[Fact]
public void Total_sums_line_totals_and_tracks_edits()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Count = 2;
vm.Lines[0].UnitaryCost = 15.5m;
Assert.Equal(31m, vm.Total);
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
vm.AddLineCommand.Execute(null);
vm.Lines[1].Count = 1;
vm.Lines[1].UnitaryCost = 9m;
Assert.Equal(40m, vm.Total);
}
[Fact]
public async Task Send_without_title_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("titre", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_without_any_line_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines.Clear();
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("ligne", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Description = "Oubli du nom";
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("nom", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = " Devis coupe ";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].Count = 2.4m;
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
Assert.Equal("Devis coupe", payload.Title);
Assert.Equal("Coupe simple", payload.Description);
Assert.Empty(payload.AttachedFiles);
Assert.Empty(payload.AttachedGraphics);
var line = Assert.Single(payload.Bill);
Assert.Equal("Coupe", line.Name);
Assert.Equal(2, line.Count);
Assert.Equal(25m, line.UnitaryCost);
Assert.Equal("EUR", line.Currency);
}
[Fact]
public async Task Send_marks_the_page_as_sent_and_disables_resend()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.True(vm.HasSent);
Assert.False(vm.SendCommand.CanExecute(null));
Assert.Equal("Devis envoyé", vm.SendLabel);
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
Assert.Contains("#7", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_surfaces_server_errors_as_error_status()
{
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
await vm.SendCommand.ExecuteAsync(null);
Assert.False(vm.HasSent);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
Assert.True(vm.SendCommand.CanExecute(null));
}
[Fact]
public async Task Send_accepts_negative_amounts_for_discount_lines()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis avec remise";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
vm.AddLineCommand.Execute(null);
vm.Lines[1].Name = "Remise fidélité";
vm.Lines[1].Description = "Remise client régulier";
vm.Lines[1].UnitaryCost = -5m;
Assert.Equal(20m, vm.Total);
await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
}
private sealed class StubEstimateApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public HttpMethod? LastMethod { get; private set; }
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (Failure is not null)
{
throw Failure;
}
if (typeof(T) == typeof(EstimateCreatedDto))
{
var payload = (EstimateDto)body!;
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
return Task.FromResult((T)(object)created);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -23,13 +23,11 @@ public class PostItViewModelTests
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id);

View file

@ -126,6 +126,36 @@ public class ProviderOngoingRequestsPageViewModelTests
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
}
[Fact]
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
await vm.InitializeAsync();
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
[Fact]
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
private sealed class StubProviderApi : IYavscApiClient
{
public HttpClient Http { get; } = new();

View file

@ -30,6 +30,7 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -60,6 +61,8 @@ public static class ServiceCollectionHelpers
services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -69,6 +72,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();

View file

@ -53,6 +53,7 @@ public class ViewLocator : IDataTemplate
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -0,0 +1,293 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Edition d'un devis (<c>Estimate</c>) créé en réponse à une demande
/// client (<see cref="BillingQuerySummaryDto"/>) consultée depuis la
/// page « Mes demandes en cours ». L'envoi poste le devis sur
/// <c>api/v1/estimate</c>; côté serveur, la commande liée
/// (<see cref="BillingQuerySummaryDto.Id"/>) est alors marquée comme
/// validée par le prestataire.
/// </summary>
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id;
public string ClientId => _query.ClientId;
public string BillingCode => _query.BillingCode;
public string Title => $"Devis — demande #{_query.Id}";
public string ContextLabel
=> $"Demande #{_query.Id} · {BillingCode} · client {ClientId}";
public string QueryDescription => string.IsNullOrWhiteSpace(_query.Description)
? "(sans description)"
: _query.Description;
[ObservableProperty]
public partial string EstimateTitle { get; set; } = string.Empty;
[ObservableProperty]
public partial string EstimateDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<EstimateLineItemViewModel> Lines { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))]
public partial EstimateLineItemViewModel? SelectedLine { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool IsBusy { get; set; }
/// <summary>
/// True une fois le devis accepté par le serveur: l'envoi est
/// désactivé pour éviter les doublons, il ne reste que « Retour ».
/// </summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool HasSent { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
public string SendLabel => HasSent ? "Devis envoyé" : "Envoyer le devis";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
{
_query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged;
AddLine();
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
}
[RelayCommand]
private void AddLine()
{
var line = new EstimateLineItemViewModel();
Lines.Add(line);
SelectedLine = line;
}
private bool CanRemoveLine() => SelectedLine is not null && !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanRemoveLine))]
private void RemoveLine()
{
if (SelectedLine is null)
{
return;
}
var index = Lines.IndexOf(SelectedLine);
Lines.Remove(SelectedLine);
SelectedLine = Lines.Count == 0
? null
: Lines[Math.Min(index, Lines.Count - 1)];
}
private bool CanSend() => !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanSend))]
private async Task SendAsync()
{
if (!TryValidate(out var validationMessage))
{
this.SetWarningStatus(validationMessage);
return;
}
IsBusy = true;
try
{
var payload = BuildPayload();
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true;
OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé à l'API devis (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi du devis: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
internal EstimateDto BuildPayload()
{
return new EstimateDto
{
CommandId = QueryId,
ClientId = ClientId,
CommandType = BillingCode,
Title = EstimateTitle.Trim(),
Description = EstimateDescription.Trim(),
Bill = Lines.Select(line => new EstimateLineDto
{
Id = line.Id,
Name = line.Name.Trim(),
Description = line.Description.Trim(),
Count = Math.Max(1, (int)Math.Round(line.Count)),
UnitaryCost = line.UnitaryCost,
Currency = string.IsNullOrWhiteSpace(line.Currency) ? "EUR" : line.Currency.Trim(),
}).ToList(),
};
}
private bool TryValidate(out string message)
{
if (string.IsNullOrWhiteSpace(EstimateTitle))
{
message = "Le titre du devis est requis.";
return false;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
message = "La demande sélectionnée n'identifie pas de client.";
return false;
}
if (string.IsNullOrWhiteSpace(BillingCode))
{
message = "La demande sélectionnée n'a pas de code de facturation.";
return false;
}
if (Lines.Count == 0)
{
message = "Ajoutez au moins une ligne au devis.";
return false;
}
foreach (var line in Lines)
{
if (string.IsNullOrWhiteSpace(line.Name))
{
message = "Chaque ligne doit avoir un nom.";
return false;
}
if (line.Name.Trim().Length > 256)
{
message = $"Le nom de la ligne « {line.Name.Trim()[..20]}… » dépasse 256 caractères.";
return false;
}
if (string.IsNullOrWhiteSpace(line.Description))
{
message = $"La ligne « {line.Name.Trim()} » doit avoir une description.";
return false;
}
if (line.Description.Trim().Length > 512)
{
message = $"La description de la ligne « {line.Name.Trim()} » dépasse 512 caractères.";
return false;
}
if (line.Count < 1)
{
message = $"La quantité de la ligne « {line.Name.Trim()} » doit être d'au moins 1.";
return false;
}
}
message = string.Empty;
return true;
}
private void OnLinesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged -= OnLinePropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged += OnLinePropertyChanged;
}
}
RaiseTotalsChanged();
}
private void OnLinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(EstimateLineItemViewModel.LineTotal)
or nameof(EstimateLineItemViewModel.Currency))
{
RaiseTotalsChanged();
}
}
private void RaiseTotalsChanged()
{
OnPropertyChanged(nameof(Total));
OnPropertyChanged(nameof(TotalLabel));
}
}

View file

@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
/// <summary>
/// Editable estimate line. <see cref="Count"/> is exposed as a
/// <see cref="decimal"/> so it binds directly to
/// <c>NumericUpDown.Value</c> (<c>decimal?</c>); it is rounded back
/// to an integer when the DTO is built.
/// </summary>
public partial class EstimateLineItemViewModel : ObservableObject
{
public long Id { get; set; }
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
[ObservableProperty]
public partial string Description { get; set; } = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal Count { get; set; } = 1m;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal UnitaryCost { get; set; }
[ObservableProperty]
public partial string Currency { get; set; } = "EUR";
public decimal LineTotal => Count * UnitaryCost;
public string LineTotalLabel => $"{LineTotal:0.00}";
}

View file

@ -22,6 +22,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
public const string SortByStatus = "Statut (en cours d'abord)";
private readonly BillingApiClient _billingClient;
private readonly EstimateApiClient? _estimateClient;
private readonly Settings? _settings;
private List<BillingQuerySummaryDto> _allQueries = new();
@ -42,6 +43,8 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
public partial string SelectedSortOption { get; set; } = SortByDate;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenSelectedEditorCommand))]
[NotifyCanExecuteChangedFor(nameof(CreateEstimateForSelectedCommand))]
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
[ObservableProperty]
@ -67,9 +70,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
protected set { _ = value; }
}
public ProviderOngoingRequestsPageViewModel(BillingApiClient billingClient, Settings? settings = null)
public ProviderOngoingRequestsPageViewModel(
BillingApiClient billingClient,
Settings? settings = null,
EstimateApiClient? estimateClient = null)
{
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_estimateClient = estimateClient;
_settings = settings;
if (_settings is not null)
@ -210,6 +217,33 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
}
}
private bool CanCreateEstimateForSelected() => SelectedQuery is not null && _estimateClient is not null;
[RelayCommand(CanExecute = nameof(CanCreateEstimateForSelected))]
public async Task CreateEstimateForSelectedAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
if (_estimateClient is null)
{
this.SetWarningStatus("Le client devis n'est pas disponible.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
partial void OnFilterTextChanged(string value)
{
ApplyFilter();

View file

@ -65,7 +65,9 @@ public class HomePageViewModel : ViewModelBase
throw new InvalidOperationException("Client billing indisponible.");
}
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings);
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}

View file

@ -0,0 +1,151 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel"
Header="Edition de devis">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="Demande"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Text="{Binding QueryDescription}"
TextWrapping="Wrap"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="Titre"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBox Grid.Row="1"
Grid.Column="2"
Text="{Binding EstimateTitle}"
PlaceholderText="Titre du devis" />
<TextBlock Grid.Row="2"
Grid.Column="0"
Text="Description"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBox Grid.Row="2"
Grid.Column="2"
Text="{Binding EstimateDescription}"
PlaceholderText="Description détaillée du devis"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="70" />
</Grid>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="Lignes du devis"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter une ligne"
Command="{Binding AddLineCommand}" />
<Button Grid.Column="3"
Content="Retirer la ligne"
Command="{Binding RemoveLineCommand}" />
</Grid>
<ListBox ItemsSource="{Binding Lines}"
SelectedItem="{Binding SelectedLine, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EstimateLineItemViewModel">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,8">
<Grid ColumnDefinitions="*,8,110,8,130,8,90"
RowDefinitions="Auto,Auto"
RowSpacing="6">
<TextBox Grid.Row="0"
Grid.Column="0"
Text="{Binding Name}"
PlaceholderText="Nom de la ligne" />
<NumericUpDown Grid.Row="0"
Grid.Column="2"
Value="{Binding Count}"
Minimum="1"
Increment="1"
FormatString="0" />
<NumericUpDown Grid.Row="0"
Grid.Column="4"
Value="{Binding UnitaryCost}"
Increment="0.5"
FormatString="0.00" />
<TextBlock Grid.Row="0"
Grid.Column="6"
Text="{Binding LineTotalLabel}"
VerticalAlignment="Center"
HorizontalAlignment="Right"
FontWeight="SemiBold" />
<TextBox Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="7"
Text="{Binding Description}"
PlaceholderText="Description de la ligne" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="1"
Text="{Binding TotalLabel, StringFormat='Total : {0}'}"
FontSize="16"
FontWeight="Bold"
HorizontalAlignment="Right" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<Button Grid.Column="0"
Content="{Binding SendLabel}"
Command="{Binding SendCommand}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
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 EstimateEditionPage : ContentPage
{
public EstimateEditionPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -87,6 +87,9 @@
<Button Content="Ouvrir en édition"
Command="{Binding OpenSelectedEditorCommand}"
/>
<Button Content="Créer un devis"
Command="{Binding CreateEstimateForSelectedCommand}"
/>
</StackPanel>
</Grid>

View file

@ -15,10 +15,11 @@
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
<ScrollViewer
VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
>
<StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// A single billable line of an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.CommandLine</c> entity.
/// </summary>
public sealed class EstimateLineDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Count { get; set; } = 1;
public decimal UnitaryCost { get; set; }
public long EstimateId { get; set; }
public string Currency { get; set; } = "EUR";
}
/// <summary>
/// Estimate payload exchanged with the <c>api/v1/estimate</c> routes
/// (<c>EstimateApiController</c>). <see cref="AttachedGraphics"/> and
/// <see cref="AttachedFiles"/> are always initialised: the server-side
/// entity reads them from non-nullable string properties and a null
/// list would break its serialisation.
/// </summary>
public sealed class EstimateDto
{
public long Id { get; set; }
public long? CommandId { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<EstimateLineDto> Bill { get; set; } = new();
public List<string> AttachedGraphics { get; set; } = new();
public List<string> AttachedFiles { get; set; } = new();
public string? OwnerId { get; set; }
public string ClientId { get; set; } = string.Empty;
public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; }
}
/// <summary>
/// Response of a successful estimate creation
/// (<c>Ok(new { estimate.Id, estimate.Bill })</c>).
/// </summary>
public sealed class EstimateCreatedDto
{
public long Id { get; set; }
public List<EstimateLineDto> Bill { get; set; } = new();
}

View file

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the estimate routes (<c>api/v1/estimate</c>,
/// served by <c>EstimateApiController</c>). Follows the same
/// DTO↔path mapper shape as <see cref="BillingApiClient"/>: all
/// transport concerns (base URL, JSON, Bearer auth, silent refresh
/// on 401) are delegated to <see cref="IYavscApiClient"/>.
/// </summary>
public sealed class EstimateApiClient
{
private const string PathPrefix = "estimate";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public EstimateApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public EstimateApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
// Validate initial value early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Lists the estimates of the given owner; when <paramref name="ownerId"/>
/// is null, the server falls back to the current user.
/// </summary>
public Task<List<EstimateDto>> GetEstimatesAsync(string? ownerId = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(ownerId)
? PathPrefix
: $"{PathPrefix}?ownerId={Uri.EscapeDataString(ownerId)}";
return _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<EstimateDto> GetEstimateAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
/// <summary>
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
/// the server also stamps the linked command as validated.
/// </summary>
public async Task<EstimateCreatedDto> CreateAsync(EstimateDto estimate, CancellationToken ct = default)
{
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
var created = await _api.CallAsync<EstimateCreatedDto>(
HttpMethod.Post,
Absolute(PathPrefix),
body: estimate,
ct: ct).ConfigureAwait(false);
return created ?? new EstimateCreatedDto();
}
public Task UpdateAsync(long id, EstimateDto estimate, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
estimate.Id = id;
return _api.CallAsync(
HttpMethod.Put,
Absolute($"{PathPrefix}/{id}"),
body: estimate,
ct: ct);
}
public Task DeleteAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -0,0 +1,209 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateApiControllerTests(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;
}
/// <summary>
/// Seed a provider (alice) and a client (bob) with a pending
/// <see cref="RdvQuery"/> from bob to alice, and return the
/// command id.
/// </summary>
private long SeedPendingCommand()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single();
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow.AddMinutes(-10),
DateModified = DateTime.UtcNow.AddMinutes(-10),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente de devis",
};
db.RdvQueries.Add(query);
db.SaveChanges();
return query.Id;
}
private static object NewEstimatePayload(long? commandId, string clientId, string? ownerId = null)
=> new
{
CommandId = commandId,
ClientId = clientId,
OwnerId = ownerId,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis détaillé",
AttachedFiles = Array.Empty<string>(),
AttachedGraphics = Array.Empty<string>(),
Bill = new[]
{
new { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
new { Name = "Remise", Description = "Remise fidélité", Count = 1, UnitaryCost = -20m, Currency = "EUR" },
},
};
[Fact]
public async Task PostEstimate_creates_the_estimate_and_validates_the_linked_command()
{
var commandId = SeedPendingCommand();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId, clientId: "bob", ownerId: "alice"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
Assert.True(estimateId > 0);
Assert.Equal(2, doc.RootElement.GetProperty("bill").GetArrayLength());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId);
Assert.Equal("alice", estimate.OwnerId);
Assert.Equal("bob", estimate.ClientId);
Assert.Equal(commandId, estimate.CommandId);
Assert.Equal(BillingCodes.Rdv, estimate.CommandType);
Assert.Equal(2, estimate.Bill.Count);
Assert.Contains(estimate.Bill, line => line.UnitaryCost == -20m);
// PostEstimate stamps the linked command as validated.
var query = db.RdvQueries.Single(q => q.Id == commandId);
Assert.NotNull(query.ValidationDate);
}
[Fact]
public async Task PostEstimate_without_command_creates_a_standalone_estimate()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Single(e => e.Id == estimateId);
Assert.Null(estimate.CommandId);
Assert.Equal("alice", estimate.OwnerId);
}
[Fact]
public async Task PostEstimate_for_another_owner_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob", ownerId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_with_an_unknown_command_id_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: 999999, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -77,7 +77,7 @@ namespace Yavsc.Controllers
{
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
@ -111,7 +111,7 @@ namespace Yavsc.Controllers
[HttpPost, Produces("application/json")]
public IActionResult PostEstimate([FromBody] Estimate estimate)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(Constants.AdminGroupName))
@ -183,7 +183,7 @@ namespace Yavsc.Controllers
{
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)

View file

@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace Yavsc.Models.Billing
{
@ -20,7 +21,7 @@ namespace Yavsc.Models.Billing
/// it will result in a new estimate template
/// </summary>
/// <returns></returns>
[ForeignKey("CommandId"),JsonIgnore]
[ForeignKey("CommandId"),JsonIgnore,ValidateNever]
public NominativeServiceCommand? Query { get; set; }
public string Description { get; set; }
public string Title { get; set; }
@ -57,14 +58,15 @@ namespace Yavsc.Models.Billing
set { AttachedFiles = value.Split(':').ToList(); }
}
[ValidateNever]
public string OwnerId { get; set; }
[ForeignKey("OwnerId"),JsonIgnore]
[ForeignKey("OwnerId"),JsonIgnore,ValidateNever]
public virtual PerformerProfile Owner { get; set; }
[Required]
public string ClientId { get; set; }
[ForeignKey("ClientId"),JsonIgnore]
[ForeignKey("ClientId"),JsonIgnore,ValidateNever]
public virtual ApplicationUser Client { get; set; }
[Required]