feat/files-control #52
19 changed files with 1235 additions and 14 deletions
commit
848f8be42f
|
|
@ -95,6 +95,15 @@ showConfig:
|
||||||
@echo CONFIGURATION: $(CONFIGURATION)
|
@echo CONFIGURATION: $(CONFIGURATION)
|
||||||
@echo BASEAPPDIR: $(BASEAPPDIR)
|
@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:
|
clean:
|
||||||
@rm -rf generated
|
@rm -rf generated
|
||||||
|
|
||||||
|
|
|
||||||
270
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal file
270
src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,13 +23,11 @@ public class PostItViewModelTests
|
||||||
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
|
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
|
||||||
|
|
||||||
viewModel.SearchText = "search";
|
viewModel.SearchText = "search";
|
||||||
viewModel.SearchCommand.Execute(null);
|
|
||||||
|
|
||||||
Assert.Single(viewModel.FilteredPosts);
|
Assert.Single(viewModel.FilteredPosts);
|
||||||
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
|
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
|
||||||
|
|
||||||
viewModel.SearchText = "bob";
|
viewModel.SearchText = "bob";
|
||||||
viewModel.SearchCommand.Execute(null);
|
|
||||||
|
|
||||||
Assert.Single(viewModel.FilteredPosts);
|
Assert.Single(viewModel.FilteredPosts);
|
||||||
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
|
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,36 @@ public class ProviderOngoingRequestsPageViewModelTests
|
||||||
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
|
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
|
private sealed class StubProviderApi : IYavscApiClient
|
||||||
{
|
{
|
||||||
public HttpClient Http { get; } = new();
|
public HttpClient Http { get; } = new();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ public static class ServiceCollectionHelpers
|
||||||
() => settings.ApiUrl,
|
() => settings.ApiUrl,
|
||||||
() => settings.Authentication?.Authority);
|
() => settings.Authentication?.Authority);
|
||||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||||
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -60,6 +61,8 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<BillingQueriesPage>();
|
services.AddTransient<BillingQueriesPage>();
|
||||||
services.AddTransient<BillingQueryDetailsPage>();
|
services.AddTransient<BillingQueryDetailsPage>();
|
||||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||||
|
services.AddTransient<EstimateEditionPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
services.AddSingleton<YavscApiClient>(api);
|
services.AddSingleton<YavscApiClient>(api);
|
||||||
|
|
@ -69,6 +72,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(userSearchClient);
|
services.AddSingleton(userSearchClient);
|
||||||
services.AddSingleton(activityClient);
|
services.AddSingleton(activityClient);
|
||||||
services.AddSingleton(billingClient);
|
services.AddSingleton(billingClient);
|
||||||
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ public class ViewLocator : IDataTemplate
|
||||||
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
|
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
|
||||||
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
||||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||||
|
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
public const string SortByStatus = "Statut (en cours d'abord)";
|
public const string SortByStatus = "Statut (en cours d'abord)";
|
||||||
|
|
||||||
private readonly BillingApiClient _billingClient;
|
private readonly BillingApiClient _billingClient;
|
||||||
|
private readonly EstimateApiClient? _estimateClient;
|
||||||
private readonly Settings? _settings;
|
private readonly Settings? _settings;
|
||||||
private List<BillingQuerySummaryDto> _allQueries = new();
|
private List<BillingQuerySummaryDto> _allQueries = new();
|
||||||
|
|
||||||
|
|
@ -42,6 +43,8 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
public partial string SelectedSortOption { get; set; } = SortByDate;
|
public partial string SelectedSortOption { get; set; } = SortByDate;
|
||||||
|
|
||||||
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(OpenSelectedEditorCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(CreateEstimateForSelectedCommand))]
|
||||||
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
|
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|
@ -67,9 +70,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
protected set { _ = value; }
|
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));
|
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||||
|
_estimateClient = estimateClient;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
|
||||||
if (_settings is not null)
|
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)
|
partial void OnFilterTextChanged(string value)
|
||||||
{
|
{
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ public class HomePageViewModel : ViewModelBase
|
||||||
throw new InvalidOperationException("Client billing indisponible.");
|
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 vm.InitializeAsync();
|
||||||
await app.PushPageAsync(vm);
|
await app.PushPageAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
Normal file
151
src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
Normal 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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,9 @@
|
||||||
<Button Content="Ouvrir en édition"
|
<Button Content="Ouvrir en édition"
|
||||||
Command="{Binding OpenSelectedEditorCommand}"
|
Command="{Binding OpenSelectedEditorCommand}"
|
||||||
/>
|
/>
|
||||||
|
<Button Content="Créer un devis"
|
||||||
|
Command="{Binding CreateEstimateForSelectedCommand}"
|
||||||
|
/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:BlogsViewModel />
|
<vm:BlogsViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Visible"
|
<ScrollViewer
|
||||||
|
VerticalScrollBarVisibility="Visible"
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
Padding="5"
|
Padding="5"
|
||||||
Margin="5"
|
Margin="5"
|
||||||
|
|
|
||||||
52
src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
Normal file
52
src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
Normal 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();
|
||||||
|
}
|
||||||
110
src/Yavsc.Api.Client/EstimateApiClient.cs
Normal file
110
src/Yavsc.Api.Client/EstimateApiClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/Yavsc.Api.Test/EstimateApiControllerTests.cs
Normal file
209
src/Yavsc.Api.Test/EstimateApiControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,7 +77,7 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
}
|
}
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
if (!User.IsInRole(Constants.AdminGroupName))
|
if (!User.IsInRole(Constants.AdminGroupName))
|
||||||
{
|
{
|
||||||
if (uid != estimate.OwnerId)
|
if (uid != estimate.OwnerId)
|
||||||
|
|
@ -111,7 +111,7 @@ namespace Yavsc.Controllers
|
||||||
[HttpPost, Produces("application/json")]
|
[HttpPost, Produces("application/json")]
|
||||||
public IActionResult PostEstimate([FromBody] Estimate estimate)
|
public IActionResult PostEstimate([FromBody] Estimate estimate)
|
||||||
{
|
{
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
if (estimate.OwnerId == null) estimate.OwnerId = uid;
|
if (estimate.OwnerId == null) estimate.OwnerId = uid;
|
||||||
|
|
||||||
if (!User.IsInRole(Constants.AdminGroupName))
|
if (!User.IsInRole(Constants.AdminGroupName))
|
||||||
|
|
@ -183,7 +183,7 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.GetUserId();
|
||||||
if (!User.IsInRole(Constants.AdminGroupName))
|
if (!User.IsInRole(Constants.AdminGroupName))
|
||||||
{
|
{
|
||||||
if (uid != estimate.OwnerId)
|
if (uid != estimate.OwnerId)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||||
|
|
||||||
namespace Yavsc.Models.Billing
|
namespace Yavsc.Models.Billing
|
||||||
{
|
{
|
||||||
|
|
@ -20,7 +21,7 @@ namespace Yavsc.Models.Billing
|
||||||
/// it will result in a new estimate template
|
/// it will result in a new estimate template
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[ForeignKey("CommandId"),JsonIgnore]
|
[ForeignKey("CommandId"),JsonIgnore,ValidateNever]
|
||||||
public NominativeServiceCommand? Query { get; set; }
|
public NominativeServiceCommand? Query { get; set; }
|
||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
@ -57,14 +58,15 @@ namespace Yavsc.Models.Billing
|
||||||
set { AttachedFiles = value.Split(':').ToList(); }
|
set { AttachedFiles = value.Split(':').ToList(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ValidateNever]
|
||||||
public string OwnerId { get; set; }
|
public string OwnerId { get; set; }
|
||||||
|
|
||||||
[ForeignKey("OwnerId"),JsonIgnore]
|
[ForeignKey("OwnerId"),JsonIgnore,ValidateNever]
|
||||||
public virtual PerformerProfile Owner { get; set; }
|
public virtual PerformerProfile Owner { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
[ForeignKey("ClientId"),JsonIgnore]
|
[ForeignKey("ClientId"),JsonIgnore,ValidateNever]
|
||||||
public virtual ApplicationUser Client { get; set; }
|
public virtual ApplicationUser Client { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue