diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs index 16f01c5e9..accd22ded 100644 --- a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -12,4 +12,68 @@ public class HomePageProviderFlowTests Assert.NotNull(vm.OpenProviderRequests); Assert.True(vm.OpenProviderRequests.CanExecute(null)); } + + [Fact] + public void HomePage_exposes_profile_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenProfile); + Assert.True(vm.OpenProfile.CanExecute(null)); + } + + [Fact] + public void HomePage_exposes_performer_configuration_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenPerformerConfiguration); + Assert.True(vm.OpenPerformerConfiguration.CanExecute(null)); + } + + [Fact] + public void HomePage_exposes_administration_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenAdministration); + Assert.True(vm.OpenAdministration.CanExecute(null)); + } + + [Fact] + public void Administration_page_filters_users_by_search_text() + { + var vm = new AdministrationPageViewModel(); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice" }); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob" }); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol" }); + + vm.SearchText = "ali"; + + Assert.Single(vm.FilteredUsers); + Assert.Equal("alice", vm.FilteredUsers[0].UserName); + } + + [Fact] + public void UserProfilePage_exposes_full_name_and_address_fields() + { + var vm = new UserProfilePageViewModel(); + + Assert.Equal("—", vm.FullName); + Assert.Equal("—", vm.Address); + Assert.NotNull(vm.RefreshCommand); + } + + [Fact] + public void UserProfilePage_can_save_profile_values() + { + var vm = new UserProfilePageViewModel + { + FullName = "Alice Example", + Address = "14 rue de l’Érable" + }; + + Assert.NotNull(vm.SaveProfileCommand); + Assert.True(vm.SaveProfileCommand.CanExecute(null)); + } } diff --git a/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs b/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs new file mode 100644 index 000000000..407183b77 --- /dev/null +++ b/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs @@ -0,0 +1,117 @@ +using System.Net.Http; +using System.Text.Json; +using Yavsc.Abstract.Resources; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class ResourceUsageApiClientTests +{ + [Fact] + public async Task GetCurrentAsync_calls_user_usage_endpoint() + { + var fake = new FakeYavscApiClient(); + var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/"); + + var usage = await client.GetCurrentAsync(); + + Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath); + Assert.Equal("GET", fake.LastMethod); + Assert.Equal(12.5m, usage.ApiCalls); + Assert.Equal(3.2m, usage.CpuSeconds); + } + + [Fact] + public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint() + { + var fake = new FakeYavscApiClient(); + var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/"); + + var usage = await client.GetAdminOverviewAsync(); + + Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath); + Assert.Equal("GET", fake.LastMethod); + Assert.Equal(2, usage.Count); + Assert.Equal("administrator", usage[0].UserName); + } + + private sealed class FakeYavscApiClient : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public string? LastPath { get; private set; } + public string? LastMethod { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method.Method; + LastPath = path; + + if (typeof(T) == typeof(ResourceUsageSummary)) + { + var summary = new ResourceUsageSummary + { + UserId = "user-42", + UserName = "alice", + ApiCalls = 12.5m, + CpuSeconds = 3.2m, + BandwidthMb = 18.4m, + StorageMb = 51.2m, + EstimatedCompensation = 4.9m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + }; + + return Task.FromResult((T)(object)summary); + } + + if (typeof(T) == typeof(List)) + { + var list = new List + { + new() + { + UserId = "admin-user", + UserName = "administrator", + ApiCalls = 90m, + CpuSeconds = 14m, + BandwidthMb = 500m, + StorageMb = 2048m, + EstimatedCompensation = 32m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + }, + new() + { + UserId = "user-99", + UserName = "bob", + ApiCalls = 40m, + CpuSeconds = 7m, + BandwidthMb = 120m, + StorageMb = 600m, + EstimatedCompensation = 15m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + } + }; + + return Task.FromResult((T)(object)list); + } + + throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}"); + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => Task.CompletedTask; + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => Task.CompletedTask; + + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + => 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 3d135987b..22885aca8 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -76,6 +76,9 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // ViewModels @@ -95,6 +98,9 @@ public static class ServiceCollectionHelpers services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 01ecb0c25..986ddc92f 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -60,6 +60,9 @@ public class ViewLocator : IDataTemplate EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), MyFilesViewModel => services.GetRequiredService(), + UserProfilePageViewModel => services.GetRequiredService(), + PerformerConfigurationPageViewModel => services.GetRequiredService(), + AdministrationPageViewModel => services.GetRequiredService(), ResourceUsagePageViewModel => services.GetRequiredService(), ChatViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, diff --git a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs new file mode 100644 index 000000000..596064aa7 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Identity; + +namespace PostIt.ViewModels; + +public partial class AdministrationPageViewModel : ViewModelBase +{ + private readonly YavscApiClient? _api; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial ObservableCollection Users { get; set; } = new(); + + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Chargement…"; + + public IReadOnlyList FilteredUsers => + string.IsNullOrWhiteSpace(SearchText) + ? Users.ToList() + : Users.Where(user => + (user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public AdministrationPageViewModel(YavscApiClient? api) + { + _api = api; + } + + public AdministrationPageViewModel() : this(null) { } + + public Task InitializeAsync() => RefreshAsync(); + + partial void OnSearchTextChanged(string value) + { + OnPropertyChanged(nameof(FilteredUsers)); + } + + [RelayCommand] + public async Task RefreshAsync() + { + if (_api is null) + { + StatusMessage = "Client API d’administration indisponible."; + return; + } + + IsBusy = true; + try + { + var query = SearchText.Trim(); + var usersEndpoint = string.IsNullOrWhiteSpace(query) + ? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString() + : new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString(); + + var users = await _api.CallAsync>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true); + + Users.Clear(); + foreach (var user in users ?? []) + { + Users.Add(user); + } + + OnPropertyChanged(nameof(FilteredUsers)); + StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé."; + } + catch (Exception ex) + { + Users.Clear(); + OnPropertyChanged(nameof(FilteredUsers)); + StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 56a3979e0..6a29afbc6 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -36,6 +36,9 @@ public class HomePageViewModel : ViewModelBase OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); + OpenProfile = new AsyncRelayCommand(OpenProfileAsync); + OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync); + OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync); OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync); OpenChat = new AsyncRelayCommand(OpenChatAsync); } @@ -43,6 +46,9 @@ public class HomePageViewModel : ViewModelBase public IAsyncRelayCommand OpenChat { get; } public IAsyncRelayCommand OpenBlogs { get; } + public IAsyncRelayCommand OpenProfile { get; } + public IAsyncRelayCommand OpenPerformerConfiguration { get; } + public IAsyncRelayCommand OpenAdministration { get; } public IAsyncRelayCommand OpenResourceUsage { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } @@ -140,6 +146,60 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenProfileAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page profil indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + + private async Task OpenPerformerConfigurationAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page performer indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + + private async Task OpenAdministrationAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page administration indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + private async Task OpenResourceUsageAsync() { var app = (App?)Application.Current; diff --git a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs new file mode 100644 index 000000000..c47fe8b71 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs @@ -0,0 +1,125 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Identity; + +namespace PostIt.ViewModels; + +public partial class UserProfilePageViewModel : ViewModelBase +{ + private readonly YavscApiClient? _api; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string UserName { get; set; } = "—"; + + [ObservableProperty] + public partial string FullName { get; set; } = "—"; + + [ObservableProperty] + public partial string Email { get; set; } = "—"; + + [ObservableProperty] + public partial string Address { get; set; } = "—"; + + [ObservableProperty] + public partial string Avatar { get; set; } = "—"; + + [ObservableProperty] + public partial string Roles { get; set; } = "—"; + + [ObservableProperty] + public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré"; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public UserProfilePageViewModel(YavscApiClient? api) + { + _api = api; + } + + public UserProfilePageViewModel() : this(null) { } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + try + { + var me = await _api.CallAsync(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true); + UserName = me?.UserName ?? "—"; + FullName = me?.FullName ?? "—"; + Email = me?.Email ?? "—"; + Address = me?.Address ?? "—"; + Avatar = string.IsNullOrWhiteSpace(me?.Avatar) ? "Aucun avatar" : me.Avatar; + Roles = me?.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle"; + DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me?.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar; + StatusMessage = "Profil chargé."; + } + catch (Exception ex) + { + UserName = "—"; + FullName = "—"; + Email = "—"; + Address = "—"; + Avatar = "—"; + Roles = "—"; + DedicatedGoogleCalendar = "Non configuré"; + StatusMessage = $"Impossible de charger le profil : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task SaveProfileAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + try + { + var payload = new ProfileUpdateRequest + { + FullName = FullName, + Address = Address + }; + + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString(); + await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true); + StatusMessage = "Profil enregistré."; + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs new file mode 100644 index 000000000..bf8318ab5 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class ActivitySelectionOption : ObservableObject +{ + public string Code { get; } + public string Name { get; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public ActivitySelectionOption(string code, string name) + { + Code = code; + Name = name; + } +} + +public partial class PerformerConfigurationPageViewModel : ViewModelBase +{ + private readonly YavscApiClient _api; + private readonly ActivityApiClient _activityClient; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string UserName { get; set; } = "—"; + + [ObservableProperty] + public partial string ExerciseCountryCode { get; set; } = "fr"; + + [ObservableProperty] + public partial string SIREN { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Website { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool Active { get; set; } + + [ObservableProperty] + public partial bool AcceptNotifications { get; set; } + + [ObservableProperty] + public partial bool AcceptPublicContact { get; set; } + + [ObservableProperty] + public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; } + + [ObservableProperty] + public partial ObservableCollection AvailableActivities { get; set; } = new(); + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public PerformerConfigurationPageViewModel(YavscApiClient api, ActivityApiClient activityClient) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _activityClient = activityClient ?? throw new ArgumentNullException(nameof(activityClient)); + } + + public PerformerConfigurationPageViewModel() : this(null!, null!) { } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString(); + var dto = await _api.CallAsync(HttpMethod.Get, endpoint).ConfigureAwait(true); + + UserName = dto?.UserName ?? "—"; + ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode; + SIREN = dto?.SIREN ?? string.Empty; + Website = dto?.WebSite ?? string.Empty; + Active = dto?.Active ?? false; + AcceptNotifications = dto?.AcceptNotifications ?? false; + AcceptPublicContact = dto?.AcceptPublicContact ?? false; + UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false; + + var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true); + var options = Flatten(catalog) + .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .Select(a => new ActivitySelectionOption(a.Code, a.Name) + { + IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true + }) + .ToList(); + + AvailableActivities.Clear(); + foreach (var option in options) + { + AvailableActivities.Add(option); + } + + StatusMessage = "Profil performer chargé."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task SaveAsync() + { + IsBusy = true; + try + { + var dto = new PerformerProfileSettings + { + PerformerId = string.Empty, + UserName = UserName, + ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode, + SIREN = SIREN, + WebSite = Website, + Active = Active, + AcceptNotifications = AcceptNotifications, + AcceptPublicContact = AcceptPublicContact, + UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients, + SelectedActivityCodes = AvailableActivities + .Where(a => a.IsSelected) + .Select(a => a.Code) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + }; + + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString(); + var response = await _api.CallAsync(HttpMethod.Put, endpoint, dto).ConfigureAwait(true); + StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private static IEnumerable Flatten(IEnumerable activities) + { + foreach (var activity in activities) + { + yield return activity; + foreach (var child in Flatten(activity.Children ?? Enumerable.Empty())) + { + yield return child; + } + } + } +} diff --git a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml new file mode 100644 index 000000000..fe2305f16 --- /dev/null +++ b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml @@ -0,0 +1,42 @@ + + + + + + + +