diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index db70e25fa..17efd391b 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests 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); + var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient); } [Fact] @@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests 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(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(42, payload.CommandId); Assert.Equal("cli-1", payload.ClientId); Assert.Equal("Brush", payload.CommandType); @@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - var payload = Assert.IsType(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(2, payload.Bill.Count); Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.True(vm.HasSent); @@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests public object? LastBody { get; private set; } public Exception? Failure { get; init; } - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + /// Historique complet des appels (le plus récent en dernier). + public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new(); + + private void Record(HttpMethod method, string path, object? body) { LastMethod = method; LastPath = path; LastBody = body; + Calls.Add((method, path, body)); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Record(method, path, body); if (Failure is not null) { @@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - LastMethod = method; - LastPath = path; - LastBody = body; + Record(method, path, body); return Task.CompletedTask; } @@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) => CallAsync(method, path, (object?)null, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + Record(method, path, null); + return Task.FromResult(Array.Empty()); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 2b243f2d5..9616e7089 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers var api = new YavscApiClient(settings, tokenStore); var client = new BlogApiClient(api, settings.BlogsApiUrl); + var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); @@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -77,6 +79,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(billingClient); services.AddSingleton(estimateClient); services.AddSingleton(frontClient); + services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index ca9a7ff65..687a7cbd2 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), + MyFilesViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index dbd84e5c2..f1b05b749 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -132,14 +132,18 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var frontClient = app.ServiceProvider?.GetRequiredService(); + var fsClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( Activity, Performer, Form, _billingClient, frontClient, + fsClient, details, - IsReadOnly); + IsReadOnly, + canAttach: !IsReadOnly); + await vm.InitializeAsync().ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index 9ab1b42fd..c9f122dce 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -15,8 +16,17 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt { private readonly BillingApiClient _billingClient; private readonly FrontOfficeApiClient _frontClient; + private readonly UserFilesApiClient _fsClient; private readonly BillingQueryDetailsDto _details; + /// + /// Vrai lorsque l'utilisateur courant est le client de la demande + /// et peut y attacher/détacher des fichiers de son espace perso. + /// Le fournisseur consulte et télécharge les pièces mais n'attache + /// pas (il joint ses documents au devis, côté EstimateEditionPage). + /// + public bool CanAttach { get; } + public ActivityInfo Activity { get; } public ActivityUserDisplayItem Performer { get; } public CommandFormSummary Form { get; } @@ -52,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt [ObservableProperty] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); + /// Pièces jointes rattachées à la demande (par référence). + [ObservableProperty] + public partial ObservableCollection Attachments { get; set; } = new(); + public override bool CanNavigateNext { get => false; @@ -70,20 +84,27 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt CommandFormSummary form, BillingApiClient billingClient, FrontOfficeApiClient frontClient, + UserFilesApiClient fsClient, BillingQueryDetailsDto details, - bool isReadOnly) + bool isReadOnly, + bool canAttach) { Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Form = form ?? throw new ArgumentNullException(nameof(form)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); + _fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient)); _details = details ?? throw new ArgumentNullException(nameof(details)); IsReadOnly = isReadOnly; + CanAttach = canAttach; this.SetInfoStatus("Details de commande charges."); } + /// Constructeur pour le designer Avalonia. + public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { } + [RelayCommand] private async Task OpenEditorAsync() { @@ -134,6 +155,110 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt await app.GoBackAsync().ConfigureAwait(true); } + /// Charge les pièces jointes de la demande. + public async Task InitializeAsync() + { + await LoadQueryAttachmentsAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task LoadQueryAttachmentsAsync() + { + try + { + var list = await _frontClient + .GetQueryAttachmentsAsync(BillingCode, Id) + .ConfigureAwait(true); + Attachments.Clear(); + if (list is not null) + foreach (var a in list) Attachments.Add(a); + } + catch (Exception ex) + { + // Non bloquant : les pièces jointes sont un complément. + this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}"); + } + } + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché à la demande par référence via + /// POST front/query/{id}/attachments. Réservé au client + /// (). + /// + [RelayCommand] + private async Task AddQueryAttachmentAsync() + { + if (!CanAttach) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var billingCode = BillingCode; + var queryId = Id; + var picker = new MyFilesViewModel(_fsClient, async fileId => + { + try + { + await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true); + await LoadQueryAttachmentsAsync().ConfigureAwait(true); + this.SetInfoStatus("Pièce jointe ajoutée à la demande."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de l'attachement : {ex.Message}"); + } + }); + await picker.InitializeAsync().ConfigureAwait(true); + await app.PushPageAsync(picker).ConfigureAwait(true); + } + + [RelayCommand] + private async Task DetachQueryFileAsync(AttachmentDto? attachment) + { + if (attachment is null || !CanAttach) return; + IsBusy = true; + try + { + await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true); + Attachments.Remove(attachment); + this.SetInfoStatus("Pièce jointe détachée de la demande."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du détachement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment) + { + if (attachment is null) return; + IsBusy = true; + try + { + var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true); + var name = System.IO.Path.GetFileName(attachment.Path ?? "file"); + var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + /// /// Download the estimate linked to this command as a LaTeX source /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs index 8f007dc08..1f1e4b4be 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs @@ -25,6 +25,7 @@ namespace PostIt.ViewModels; public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateApiClient _estimateClient; + private readonly UserFilesApiClient _fsClient; private readonly BillingQuerySummaryDto _query; public long QueryId => _query.Id; @@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus [ObservableProperty] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + /// Identifiant du devis, renseigné après l'envoi. + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))] + public partial long? EstimateId { get; set; } + + /// Pièces jointes rattachées au devis (par référence). + [ObservableProperty] + public partial ObservableCollection Attachments { get; set; } = new(); + public decimal Total => Lines.Sum(line => line.LineTotal); public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; @@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus protected set { _ = value; } } - public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient) + public EstimateEditionPageViewModel( + BillingQuerySummaryDto query, + EstimateApiClient estimateClient, + UserFilesApiClient fsClient) { _query = query ?? throw new ArgumentNullException(nameof(query)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); + _fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient)); EstimateDescription = query.Description ?? string.Empty; Lines.CollectionChanged += OnLinesCollectionChanged; @@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); } + /// Constructeur pour le designer Avalonia. + public EstimateEditionPageViewModel() : this(null!, null!, null!) { } + [RelayCommand] private void AddLine() { @@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); HasSent = true; + EstimateId = created.Id; OnPropertyChanged(nameof(SendLabel)); this.SetInfoStatus( $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); + await LoadAttachmentsAsync().ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { @@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus await app.GoBackAsync().ConfigureAwait(true); } + /// + /// Charge les pièces jointes rattachées au devis courant + /// (GET estimate/{id}/attachments). + /// + [RelayCommand] + private async Task LoadAttachmentsAsync() + { + if (EstimateId is null || _estimateClient is null) return; + try + { + var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true); + Attachments.Clear(); + if (list is not null) + foreach (var a in list) Attachments.Add(a); + } + catch (Exception ex) + { + this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}"); + } + } + + private bool CanAddAttachment() => EstimateId is not null && !IsBusy; + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché au devis par référence via + /// POST estimate/{id}/attachments. + /// + [RelayCommand(CanExecute = nameof(CanAddAttachment))] + private async Task AddAttachmentAsync() + { + if (EstimateId is null) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var estimateId = EstimateId.Value; + var picker = new MyFilesViewModel(_fsClient, async fileId => + { + try + { + await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true); + await LoadAttachmentsAsync().ConfigureAwait(true); + this.SetInfoStatus("Pièce jointe ajoutée au devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de l'attachement : {ex.Message}"); + } + }); + await picker.InitializeAsync().ConfigureAwait(true); + await app.PushPageAsync(picker).ConfigureAwait(true); + } + + [RelayCommand] + private async Task DetachFileAsync(AttachmentDto? attachment) + { + if (attachment is null || EstimateId is null) return; + IsBusy = true; + try + { + await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true); + Attachments.Remove(attachment); + this.SetInfoStatus("Pièce jointe détachée du devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du détachement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadAttachmentAsync(AttachmentDto? attachment) + { + if (attachment is null) return; + IsBusy = true; + try + { + var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true); + var name = System.IO.Path.GetFileName(attachment.Path ?? "file"); + var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + internal EstimateDto BuildPayload() { return new EstimateDto diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 75a5bdc9a..7fd84fd88 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var frontClient = app.ServiceProvider?.GetRequiredService(); + var fsClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( activity, @@ -162,9 +163,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi form, _billingClient, frontClient, + fsClient, details, - isReadOnly: false); + isReadOnly: false, + canAttach: false); + await vm.InitializeAsync().ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (Exception ex) @@ -243,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi throw new InvalidOperationException("Application PostIt indisponible."); } - var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient); + var fsClient = app.ServiceProvider?.GetRequiredService(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient); await app.PushPageAsync(vm).ConfigureAwait(true); } diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 9d466b26c..2c138a27d 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); + OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); } public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; } + public IAsyncRelayCommand OpenMyFiles { get; } private async Task OpenActivitiesAsync() { @@ -101,6 +103,29 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + /// + /// Ouvre la page « Mes fichiers » (espace de stockage personnel), + /// servie par le host Blogs via api/v1/fs. + /// + private async Task OpenMyFilesAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var fsClient = app.ServiceProvider?.GetRequiredService(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new MyFilesViewModel(fsClient); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs new file mode 100644 index 000000000..cc4b46828 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// Gestion de l'espace de stockage personnel (« My Files ») : +/// navigation dans l'arbre, téléversement, suppression, téléchargement. +/// Sert aussi de **sélecteur** pour attacher un fichier à un devis +/// (fournisseur) ou à une demande (client) par référence : lorsque +/// est vrai, la sélection d'un fichier invoque +/// avec l'identifiant +/// puis revient à la page précédente. +/// +public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly UserFilesApiClient _fs; + private readonly Action? _onFilePicked; + + /// + /// Sous-répertoire courant, relatif à la racine de l'espace perso + /// (chaîne vide à la racine). Servi tel quel à GET/POST fs/{subdir}. + /// + [ObservableProperty] + public partial string CurrentPath { get; set; } = string.Empty; + + public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath) + ? "Mes fichiers" + : $"Mes fichiers / {CurrentPath}"; + + public bool IsPickerMode => _onFilePicked is not null; + + public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath); + + [ObservableProperty] + public partial ObservableCollection Files { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection SubDirectories { get; set; } = new(); + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public MyFilesViewModel(UserFilesApiClient fs, Action? onFilePicked = null) + { + _fs = fs ?? throw new ArgumentNullException(nameof(fs)); + _onFilePicked = onFilePicked; + } + + /// Constructeur pour le designer Avalonia. + public MyFilesViewModel() : this(null!) { } + + public async Task InitializeAsync() + { + await RefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task RefreshAsync() + { + IsBusy = true; + try + { + var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true); + Files.Clear(); + SubDirectories.Clear(); + if (dir?.Files is not null) + foreach (var f in dir.Files) Files.Add(f); + if (dir?.SubDirectories is not null) + foreach (var d in dir.SubDirectories) SubDirectories.Add(d); + this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s)."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur de listage : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task OpenDirAsync(DirEntryDto? dir) + { + if (dir is null) return; + CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty); + OnPropertyChanged(nameof(PathLabel)); + OnPropertyChanged(nameof(CanGoUp)); + await RefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task UpAsync() + { + if (!CanGoUp) return; + CurrentPath = ParentPath(CurrentPath); + OnPropertyChanged(nameof(PathLabel)); + OnPropertyChanged(nameof(CanGoUp)); + await RefreshAsync().ConfigureAwait(true); + } + + /// Téléversement déclenché par le code-behind (picker OS). + public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection uploads) + { + if (uploads is null || uploads.Count == 0) return; + IsBusy = true; + try + { + var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true); + this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s)."); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléversement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DeleteFileAsync(FileEntryDto? file) + { + if (file is null) return; + IsBusy = true; + try + { + await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true); + this.SetInfoStatus($"Fichier supprimé."); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de la suppression : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadFileAsync(FileEntryDto? file) + { + if (file is null || file.Id is null) return; + IsBusy = true; + try + { + var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task PickFileAsync(FileEntryDto? file) + { + if (file is null || file.Id is null) + { + this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord."); + return; + } + + var callback = _onFilePicked; + if (callback is null) + { + // Mode gestion : le téléchargement est l'action par défaut. + await DownloadFileAsync(file).ConfigureAwait(true); + return; + } + + callback(file.Id.Value); + await GoBackAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task BackAsync() + { + await GoBackAsync().ConfigureAwait(true); + } + + private static async Task GoBackAsync() + { + var app = (App?)Application.Current; + if (app is null) throw new InvalidOperationException("Application PostIt indisponible."); + await app.GoBackAsync().ConfigureAwait(true); + } + + private static string CombinePath(string current, string name) + { + if (string.IsNullOrWhiteSpace(current)) return name; + return $"{current}/{name}"; + } + + private static string ParentPath(string current) + { + if (string.IsNullOrWhiteSpace(current)) return string.Empty; + var idx = current.LastIndexOf('/'); + return idx < 0 ? string.Empty : current[..idx]; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 904f17350..4cbce9a20 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -1,6 +1,7 @@ + + + + + + /// public string ContentType { get; set; } + + /// + /// Propriétaire du fichier dans l'espace de stockage personnel. + /// Renseigné pour les fichiers téléversés via api/v1/fs + /// (référencés par / + /// ). Reste null pour les + /// pièces jointes de blog historiques (), + /// qui ne sont pas adressées par propriétaire. + /// + public string? OwnerId { get; set; } + + [ForeignKey("OwnerId")] + public virtual ApplicationUser? Owner { get; set; } } [PrimaryKey(nameof(FileId), nameof(PostId))] diff --git a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs index 63627c116..602cc4bf0 100644 --- a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs +++ b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs @@ -43,11 +43,29 @@ namespace Yavsc.Server.Models.FileSystem } public string DestDir { get; set; } - + public string FileName { get; set; } public bool Overridden { get; set; } public bool QuotaOffense { get; set; } + + /// + /// Identifiant de la ligne + /// créée à l'enregistrement du fichier dans l'espace perso + /// (api/v1/fs). Nul si aucune métadonnée n'a été persistée. + /// + public long? FileId { get; set; } + + /// + /// Taille du fichier reçu (octets), pour le reflet côté métadonnées. + /// + public long Length { get; set; } + + /// + /// Type MIME du fichier reçu. + /// + public string ContentType { get; set; } + public string FullName { get => Path.Combine(DestDir, FileName); } }