diff --git a/API/Controllers/ProductsController.cs b/API/Controllers/ProductsController.cs index accf817..72ff1b2 100644 --- a/API/Controllers/ProductsController.cs +++ b/API/Controllers/ProductsController.cs @@ -156,4 +156,17 @@ public async Task OrderCategories(IList ids) await productService.OrderCategories(ids); return Ok(); } + + /// + /// Re-order products, requires all products to be part of the same category + /// + /// + /// + [HttpPost("order")] + [Authorize(Policy = PolicyConstants.ManageProducts)] + public async Task OrderProducts(IList ids) + { + await productService.OrderProducts(ids); + return Ok(); + } } diff --git a/API/Controllers/UserController.cs b/API/Controllers/UserController.cs index 560db37..5ba39ae 100644 --- a/API/Controllers/UserController.cs +++ b/API/Controllers/UserController.cs @@ -69,5 +69,20 @@ public async Task>> Search([FromQuery] string query) return Ok(await unitOfWork.UsersRepository.Search(query)); } + + /// + /// Update non OIDC synced attributes of a user + /// + /// + /// + [HttpPost] + public async Task> UpdateUser(UserDto user) + { + var updated = await userService.Update(User, user); + + var dto = mapper.Map(updated); + dto.Roles = HttpContext.User.GetRoles(); + return Ok(dto); + } } \ No newline at end of file diff --git a/API/DTOs/ProductDto.cs b/API/DTOs/ProductDto.cs index 4a895fd..faf8a7f 100644 --- a/API/DTOs/ProductDto.cs +++ b/API/DTOs/ProductDto.cs @@ -9,6 +9,7 @@ public sealed record ProductDto public string Name { get; set; } public string Description { get; set; } = string.Empty; public int CategoryId { get; set; } + public int SortValue { get; set; } public ProductType Type { get; set; } public bool IsTracked { get; set; } public bool Enabled { get; set; } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 73d5186..d7b326b 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,5 +4,6 @@ public class UserDto { public int Id { get; set; } public string Name { get; set; } + public string Language { get; set; } public IList Roles { get; set; } = []; } \ No newline at end of file diff --git a/API/Data/Repositories/ProductRepository.cs b/API/Data/Repositories/ProductRepository.cs index 32a1ffc..d08e63d 100644 --- a/API/Data/Repositories/ProductRepository.cs +++ b/API/Data/Repositories/ProductRepository.cs @@ -20,6 +20,7 @@ public interface IProductRepository Task> GetAllCategories(bool onlyEnabled = false); Task> GetAllCategoriesDtos(bool onlyEnabled = false); Task> GetByCategory(ProductCategory category); + Task GetHighestSortValue(ProductCategory category); void Add(Product product); void Add(ProductCategory category); void Update(Product product); @@ -107,6 +108,13 @@ public async Task> GetByCategory(ProductCategory category) .ToListAsync(); } + public async Task GetHighestSortValue(ProductCategory category) + { + return await ctx.Products + .Where(p => p.CategoryId == category.Id) + .MaxAsync(p => p.SortValue); + } + public void Add(Product product) { ctx.Products.Add(product).State = EntityState.Added; diff --git a/API/Entities/Product.cs b/API/Entities/Product.cs index 11c2612..7a65931 100644 --- a/API/Entities/Product.cs +++ b/API/Entities/Product.cs @@ -13,6 +13,10 @@ public class Product public int CategoryId { get; set; } public ProductCategory Category { get; set; } + /// + /// This value is valid inside a category, not between + /// + public int SortValue { get; set; } public ProductType Type { get; set; } public bool IsTracked { get; set; } diff --git a/API/I18N/nl.json b/API/I18N/nl.json new file mode 100644 index 0000000..9eac057 --- /dev/null +++ b/API/I18N/nl.json @@ -0,0 +1,8 @@ +{ + "client-update-company-number-note": "Het bedrijfsnummer van deze klant is bijgewerkt sinds deze levering werd gestart. Van: {0} naar {1}.", + "client-update-address-note": "Het adres van deze klant is bijgewerkt sinds deze levering werd gestart. Van {0} naar {1}.", + "client-update-invoice-email": "Het factuur e-mailadres van deze klant is bijgewerkt sinds deze levering werd gestart. Van {0} naar {1}.", + "stock-bulk-stocks-not-found": "Kon geen voorraad vinden voor product-id's: {0}", + "stock-bulk-insufficient-stock": "{0} heeft niet genoeg items op voorraad. Heeft {1}, nodig: {2}" + +} \ No newline at end of file diff --git a/API/ManualMigrations/ManualMigrationAddProductSortValues.cs b/API/ManualMigrations/ManualMigrationAddProductSortValues.cs new file mode 100644 index 0000000..a2ae60f --- /dev/null +++ b/API/ManualMigrations/ManualMigrationAddProductSortValues.cs @@ -0,0 +1,49 @@ +using API.Data; +using API.Entities; +using API.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace API.ManualMigrations; + +public static class ManualMigrationAddProductSortValues +{ + + public static async Task Migrate(DataContext ctx, ILogger logger) + { + if (await ctx.ManualMigrations.AnyAsync(mm => mm.Name.Equals("ManualMigrationAddProductSortValues"))) + { + return; + } + + logger.LogCritical("Running ManualMigrationAddProductSortValues migration - Please be patient, this may take some time. This is not an error"); + + + var products = await ctx.Products.ToListAsync(); + var productsByCategory = products.GroupBy(p => p.CategoryId); + + foreach (var grouping in productsByCategory) + { + var idx = 0; + using var iter = grouping.OrderBy(p => p.NormalizedName).GetEnumerator(); + while (iter.MoveNext()) + { + var product = iter.Current; + product.SortValue = idx++; + } + } + + await ctx.SaveChangesAsync(); + + await ctx.ManualMigrations.AddAsync(new ManualMigration + { + Name = "ManualMigrationAddProductSortValues", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await ctx.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrationAddProductSortValues migration - Completed. This is not an error"); + + } + +} \ No newline at end of file diff --git a/API/ManualMigrations/ManualMigrationAddStockForExistingProducts.cs b/API/ManualMigrations/ManualMigrationAddStockForExistingProducts.cs index fdcdc1d..aa417be 100644 --- a/API/ManualMigrations/ManualMigrationAddStockForExistingProducts.cs +++ b/API/ManualMigrations/ManualMigrationAddStockForExistingProducts.cs @@ -5,7 +5,7 @@ namespace API.ManualMigrations; -public class ManualMigrationAddStockForExistingProducts +public static class ManualMigrationAddStockForExistingProducts { public static async Task Migrate(DataContext ctx, ILogger logger) { diff --git a/API/Migrations/20250905152739_ProductSortValue.Designer.cs b/API/Migrations/20250905152739_ProductSortValue.Designer.cs new file mode 100644 index 0000000..7755e0b --- /dev/null +++ b/API/Migrations/20250905152739_ProductSortValue.Designer.cs @@ -0,0 +1,436 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace API.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250905152739_ProductSortValue")] + partial class ProductSortValue + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("API.Entities.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompanyNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("API.Entities.Delivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipientId") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("SystemMessages") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecipientId"); + + b.HasIndex("UserId"); + + b.ToTable("Deliveries"); + }); + + modelBuilder.Entity("API.Entities.DeliveryLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeliveryId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId"); + + b.ToTable("DeliveryLines"); + }); + + modelBuilder.Entity("API.Entities.ManualMigration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProductVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrations"); + }); + + modelBuilder.Entity("API.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsTracked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortValue") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("API.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoCollapse") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortValue") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ProductCategories"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("integer"); + + b.Property("RowVersion") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("API.Entities.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("RowVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductStock"); + }); + + modelBuilder.Entity("API.Entities.StockHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Operation") + .HasColumnType("integer"); + + b.Property("QuantityAfter") + .HasColumnType("integer"); + + b.Property("QuantityBefore") + .HasColumnType("integer"); + + b.Property("ReferenceNumber") + .HasColumnType("text"); + + b.Property("StockId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("StockId"); + + b.HasIndex("UserId"); + + b.ToTable("StockHistory"); + }); + + modelBuilder.Entity("API.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Language") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("API.Entities.Delivery", b => + { + b.HasOne("API.Entities.Client", "Recipient") + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.User", "From") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("From"); + + b.Navigation("Recipient"); + }); + + modelBuilder.Entity("API.Entities.DeliveryLine", b => + { + b.HasOne("API.Entities.Delivery", "Delivery") + .WithMany("Lines") + .HasForeignKey("DeliveryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Delivery"); + }); + + modelBuilder.Entity("API.Entities.Product", b => + { + b.HasOne("API.Entities.ProductCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("API.Entities.Stock", b => + { + b.HasOne("API.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("API.Entities.StockHistory", b => + { + b.HasOne("API.Entities.Stock", "Stock") + .WithMany("History") + .HasForeignKey("StockId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stock"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Delivery", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("API.Entities.Stock", b => + { + b.Navigation("History"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Migrations/20250905152739_ProductSortValue.cs b/API/Migrations/20250905152739_ProductSortValue.cs new file mode 100644 index 0000000..e30d7f7 --- /dev/null +++ b/API/Migrations/20250905152739_ProductSortValue.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Migrations +{ + /// + public partial class ProductSortValue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SortValue", + table: "Products", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SortValue", + table: "Products"); + } + } +} diff --git a/API/Migrations/DataContextModelSnapshot.cs b/API/Migrations/DataContextModelSnapshot.cs index d96dd48..39f95cb 100644 --- a/API/Migrations/DataContextModelSnapshot.cs +++ b/API/Migrations/DataContextModelSnapshot.cs @@ -184,6 +184,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("SortValue") + .HasColumnType("integer"); + b.Property("Type") .HasColumnType("integer"); diff --git a/API/Program.cs b/API/Program.cs index 7079cb8..0ee0bb1 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,6 +1,7 @@ using API.Data; using API.Entities.Enums; using API.Logging; +using API.ManualMigrations; using API.Services; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; @@ -32,8 +33,11 @@ public static async Task Main(string[] args) var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); - logger.LogInformation("Migrating database"); - await context.Database.MigrateAsync(); + if ((await context.Database.GetPendingMigrationsAsync()).Any()) + { + logger.LogInformation("Migrating database"); + await context.Database.MigrateAsync(); + } logger.LogInformation("Seeding database"); await Seed.Run(context); diff --git a/API/Services/ProductService.cs b/API/Services/ProductService.cs index e321880..4c9b4d6 100644 --- a/API/Services/ProductService.cs +++ b/API/Services/ProductService.cs @@ -16,16 +16,29 @@ public interface IProductService Task DeleteProduct(int id); Task DeleteProductCategory(int id); - /** - * Sets the sort value of all categories to the index in the list - */ + /// + /// Sets the sort value of all categories to the index in the list + /// + /// + /// Task OrderCategories(IList ids); + /// + /// Orders produtcs inside a category + /// + /// + /// + Task OrderProducts(IList ids); } public class ProductService(IUnitOfWork unitOfWork, IMapper mapper): IProductService { public async Task CreateProduct(ProductDto dto) { + var category = await unitOfWork.ProductRepository.GetCategoryById(dto.CategoryId); + if (category == null) throw new InOutException("errors.category-not-found"); + + var maxSortValue = await unitOfWork.ProductRepository.GetHighestSortValue(category); + var product = new Product { Name = dto.Name, @@ -35,6 +48,7 @@ public async Task CreateProduct(ProductDto dto) Type = dto.Type, IsTracked = dto.IsTracked, Enabled = dto.Enabled, + SortValue = maxSortValue+1, }; unitOfWork.ProductRepository.Add(product); @@ -82,9 +96,18 @@ public async Task UpdateProduct(ProductDto dto) extProduct.Name = dto.Name; extProduct.NormalizedName = dto.Name.ToNormalized(); } + + if (extProduct.CategoryId != dto.CategoryId) + { + var category = await unitOfWork.ProductRepository.GetCategoryById(dto.CategoryId); + if (category == null) throw new InOutException("errors.category-not-found"); + + var maxSortValue = await unitOfWork.ProductRepository.GetHighestSortValue(category); + extProduct.CategoryId = dto.CategoryId; + extProduct.SortValue = maxSortValue + 1; + } extProduct.Description = dto.Description; - extProduct.CategoryId = dto.CategoryId; extProduct.Type = dto.Type; extProduct.IsTracked = dto.IsTracked; extProduct.Enabled = dto.Enabled; @@ -165,4 +188,30 @@ public async Task OrderCategories(IList ids) await unitOfWork.CommitAsync(); } + + public async Task OrderProducts(IList ids) + { + ids = ids.Distinct().ToList(); + var products = await unitOfWork.ProductRepository.GetByIds(ids); + if (ids.Count != products.Count) throw new InOutException("errors.not-enough-products"); + + if (products.Select(p => p.CategoryId).Distinct().Count() != 1) + { + throw new InOutException("errors.no-sorting-between-categories"); + } + + var category = await unitOfWork.ProductRepository.GetCategoryById(products.First().CategoryId); + if (category == null) throw new InOutException("errors.category-not-found"); + + var allProducts = await unitOfWork.ProductRepository.GetByCategory(category); + if (allProducts.Count != products.Count) throw new InOutException("errors.product-not-found"); + + foreach (var product in products) + { + product.SortValue = ids.IndexOf(product.Id); + unitOfWork.ProductRepository.Update(product); + } + + await unitOfWork.CommitAsync(); + } } \ No newline at end of file diff --git a/API/Services/UserService.cs b/API/Services/UserService.cs index ac48508..5b58d0e 100644 --- a/API/Services/UserService.cs +++ b/API/Services/UserService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using API.Data; +using API.DTOs; using API.Entities; using API.Extensions; @@ -13,6 +14,14 @@ public interface IUserService /// /// Task GetUser(ClaimsPrincipal principal); + + /// + /// Updates non OIDC synced attributes (preferences) + /// + /// + /// + /// + Task Update(ClaimsPrincipal principal, UserDto userDto); } public class UserService(IUnitOfWork unitOfWork): IUserService @@ -36,7 +45,18 @@ public async Task GetUser(ClaimsPrincipal principal) return user; } - + + public async Task Update(ClaimsPrincipal principal, UserDto userDto) + { + var user = await GetUser(principal); + if (user.Id != userDto.Id) throw new UnauthorizedAccessException(); + + user.Language = userDto.Language; + + await unitOfWork.CommitAsync(); + return user; + } + private async Task NewUser(ClaimsPrincipal principal) { var user = DefaultUser(principal.GetUserId()); diff --git a/API/Startup.cs b/API/Startup.cs index 955c322..98b403b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -115,6 +115,7 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, logger.LogInformation("Running Migrations"); await ManualMigrationAddStockForExistingProducts.Migrate(ctx, logger); + await ManualMigrationAddProductSortValues.Migrate(ctx, logger); logger.LogInformation("Running Migrations - complete"); }).GetAwaiter().GetResult(); diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index cc02c0c..da2d5cf 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -10,6 +10,10 @@ "actions": "Actions" }, + "dashboard": { + "title": "Dashboard" + }, + "errors": { "generic": "An error has occurred", "no-fallback-category": "Cannot delete category while no fallback category exists", @@ -24,7 +28,9 @@ "product-not-found": "Product not found", "product-in-use": "Product is tracked in stock", "name-in-use": "This name is already being used", - "not-enough-categories": "Not enough categories referenced in your request" + "not-enough-categories": "Not enough categories referenced in your request", + "not-enough-products": "Not enough products to sort", + "no-sorting-between-categories": "Cannot sort products of different categories" }, "confirm-modal": { @@ -284,6 +290,7 @@ "deliveries": "Browse deliveries", "stock": "Stock", "management": "Management", + "user": "User", "logout": "Logout" }, "management": { @@ -375,9 +382,19 @@ "selected-fields": "Selected fields", "drag-fields-here": "Drag fields here to customise your export" } + }, + "user": { + "title": "User preferences", + "language-title": "Language", + "language-subtitle": "The language in the web app and error messages." } }, + "language-pipe": { + "en": "English", + "nl": "Nederlands" + }, + "product-type-pipe": { "one-time": "One time", "consumable": "Multiple times" @@ -445,12 +462,12 @@ "auto-collapse-tooltip": "When enabled, will hide products in this category by default in the new delivery form" }, - "oidc":{ - "callback": { - "title": "Authenticating...", - "message": "Please wait while we complete your sign-in.", - "info": "This should only take a moment", - "failed": "Authentication failed. Redirecting to login..." - } + "sort-product-category-modal": { + "title": "Sort products inside {{category}}", + "create": "{{common.create}}", + "update": "{{common.update}}", + "cancel": "{{common.cancel}}", + "close": "{{common.close}}", + "header-name": "Name" } } diff --git a/UI/Web/public/assets/i18n/nl.json b/UI/Web/public/assets/i18n/nl.json new file mode 100644 index 0000000..ff243bd --- /dev/null +++ b/UI/Web/public/assets/i18n/nl.json @@ -0,0 +1,473 @@ +{ + "common": { + "edit": "Bewerken", + "close": "Sluiten", + "cancel": "Annuleren", + "save": "Opslaan", + "create": "Aanmaken", + "update": "Bijwerken", + "confirm": "Bevestigen", + "actions": "Acties" + }, + + "dashboard": { + "title": "Dashboard" + }, + + "errors": { + "generic": "Er is een fout opgetreden", + "no-fallback-category": "Kan categorie niet verwijderen zolang er geen alternatieve categorie bestaat", + "not-found": "Iets werd niet gevonden!", + "unfinished-deliveries": "Kan klant niet verwijderen, er zijn nog openstaande leveringen", + "client-already-exists": "Deze klant bestaat al", + "client-not-found": "Klant niet gevonden", + "delivery-not-found": "Levering niet gevonden", + "delivery-locked": "Deze levering kan niet worden gewijzigd", + "cannot-change-recipient": "Kan ontvanger niet wijzigen", + "stock-not-found": "Voorraad niet gevonden", + "product-not-found": "Product niet gevonden", + "product-in-use": "Product wordt gevolgd in voorraad", + "name-in-use": "Deze naam wordt al gebruikt", + "not-enough-categories": "Niet genoeg categorieën gerefereerd in uw verzoek", + "not-enough-products": "Niet genoeg producten om te sorteren", + "no-sorting-between-categories": "Kan producten van verschillende categorieën niet sorteren" + }, + + "confirm-modal": { + "title": "Bevestig uw volgende actie", + "generic": "Weet u zeker dat u door wilt gaan met deze actie?", + "cancel": "{{common.cancel}}", + "confirm": "{{common.confirm}}", + "close": "{{common.close}}" + }, + + "client-modal": { + "title": "Klant", + "description": "Klanten moeten alleen een uniek bedrijfsnummer hebben indien ingesteld, en een naam. Alle andere informatie is zonder beperkingen of validatie. We raden aan om zoveel mogelijk correcte informatie in te vullen, omdat dit het verwerken van leveringen vergemakkelijkt", + "cancel": "{{common.cancel}}", + "create": "{{common.create}}", + "close": "{{common.close}}", + "update": "{{common.update}}", + "name": "Naam", + "name-tooltip": "Naam om de klant te helpen identificeren", + "address": "Adres", + "address-tooltip": "Lever- of factuuradres van de klant", + "companyNumber": "Bedrijfsnummer", + "companyNumber-tooltip": "Unieke identificatie voor het bedrijf. Moet uniek zijn voor alle klanten.", + "contactEmail": "Contact E-mail", + "contactEmail-tooltip": "E-mailadres van het hoofdcontactpersoon voor deze klant", + "contactName": "Contactpersoon", + "contactName-tooltip": "Volledige naam van de hoofdcontactpersoon", + "contactNumber": "Contactnummer", + "contactNumber-tooltip": "Telefoonnummer van de hoofdcontactpersoon", + "invoiceEmail": "Factuur E-mail", + "invoiceEmail-tooltip": "E-mail voor het ontvangen van facturen en factureringsgerelateerde communicatie" + }, + + "import-client-modal": { + "title": "Klanten importeren uit CSV", + "cancel": "{{common.cancel}}", + "create": "{{common.create}}", + "close": "{{common.close}}", + "import": "Importeren", + "next": "Volgende", + "import-description": "Selecteer uw CSV-export om klanten in bulk te importeren vanuit een bekende bron" + }, + + "client-field-pipe": { + "name": "Naam", + "address": "Adres", + "company-number": "Bedrijfsnummer", + "contact-email": "Contact E-mail", + "contact-name": "Contactpersoon", + "contact-number": "Contactnummer", + "invoice-email": "Factuur e-mail" + }, + + "manage-delivery": { + "loading": "Laden...", + "new-delivery": "Nieuwe Levering", + "edit-delivery-with-id": "Levering #{{id}} naar {{name}} bewerken", + "unknown": "onbekend", + "items-selected": "{{count}} items geselecteerd", + "save-delivery": "Levering Opslaan", + "save": "{{common.save}}", + "consumable": "Verbruiksgoed", + "one-time": "Eenmalig", + "recipient": "Ontvanger", + "from": "Van", + "success": "Uw levering is succesvol bijgewerkt", + "failed": "Kon uw levering niet verzenden, controleer uw invoer", + "delivery-locked": "Deze levering kan niet worden gewijzigd in de huidige staat", + "message-label": "Bericht", + "message-tooltip": "Een optioneel bericht, als extra informatie" + }, + + "filter": { + "filter-title": "Leveringen filteren", + "filter-combination-and": "Voldoe aan alle filters", + "filter-combination-or": "Voldoe aan een van de filters", + "add-filter": "Statement toevoegen", + "search": "Zoeken", + "limit-label": "Limiet", + "sort-label": "Sorteren op", + "sort-direction": "Sorteerrichting" + }, + + "browse-deliveries": { + "from": "Van", + "to": "Ontvanger", + "state": "Leveringsstatus", + "size": "Grootte", + "created": "Aangemaakt op", + "actions": "Acties", + "transition": "Overgaan", + "no-deliveries": "Er werden geen leveringen gevonden voor uw filter" + }, + + "transition-delivery-modal": { + "title": "Leveringsstatus wijzigen", + "close": "{{common.close}}", + "cancel": "{{common.cancel}}", + "save": "{{common.save}}", + "saving": "{{common.saving}}", + "cur-label": "Huidige status: {{state}}", + "no-transition-possible": "U kunt de status van deze levering niet wijzigen" + }, + + "view-delivery-modal": { + "title": "Levering {{number}} naar {{to}}", + "close": "{{common.close}}", + "participants": { + "label": "Deelnemers", + "from": "Van", + "to": "Naar" + }, + "message": { + "label": "Bericht" + }, + "systemMessages": { + "label": "Systeemberichten" + }, + "lines": { + "category": "Categorie", + "label": "Items", + "product": "Product", + "quantity": "Hoeveelheid" + }, + "dates": { + "label": "Tijdstempels", + "created": "Aangemaakt", + "modified": "Laatst Gewijzigd" + } + }, + + "under-construction": { + "title": "In Ontwikkeling", + "message": "We werken hard om u iets geweldigs te brengen. Kom binnenkort terug!", + "back": "Ga Terug naar Home" + }, + + "delivery-state-tooltips": { + "in-progress": "De levering wordt nog bewerkt en mag niet worden verwerkt", + "completed": "De levering is klaar om verwerkt te worden", + "handled": "De levering is verwerkt", + "cancelled": "De levering is geannuleerd" + }, + + "browse-stock": { + "name": "Naam", + "category": "Categorie", + "description": "Beschrijving", + "quantity": "Hoeveelheid", + "actions": "Acties" + }, + + "stock-history-modal": { + "title": "Voorraadgeschiedenis: {{name}}", + "close": "{{common.close}}", + "date": "Datum", + "operation": "Bewerking", + "change": "Wijziging", + "reference": "Referentie", + "notes": "Notities", + "actions": "Acties", + "empty": "{{common.empty}}" + }, + + "edit-stock-modal": { + "title": "Voorraad bewerken: {{name}}", + "close": "{{common.close}}", + "cancel": "{{common.cancel}}", + "save": "{{common.save}}", + "saving": "{{common.saving}}", + "product-label": "Product", + "product-tooltip": "Wijs een product toe aan de voorraad, elk product mag slechts één voorraad hebben", + "name-label": "Naam", + "name-tooltip": "De naam van uw voorraad, over het algemeen gelijk aan uw product", + "description-label": "Beschrijving", + "description-tooltip": "Aanvullende info om uw voorraad te identificeren" + }, + + "update-stock-modal": { + "title": "Voorraad bijwerken: {{name}}", + "close": "{{common.close}}", + "cancel": "{{common.cancel}}", + "save": "{{common.save}}", + "saving": "{{common.saving}}", + "operation-label": "Bewerking", + "operation-tooltip": "De bewerking om uit te voeren op de geselecteerde voorraad. Toevoegen, verwijderen, instellen", + "value-label": "Waarde", + "value-tooltip": "De waarde om te gebruiken in de bewerking", + "notes-label": "Notities", + "notes-tooltip": "Optionele notities over waarom deze update moest plaatsvinden", + "reference-label": "Referentie", + "reference-tooltip": "Optionele referentie naar wat de wijziging veroorzaakt, wordt automatisch voor u gegenereerd indien leeg gelaten" + }, + + "stock-operation-pipe": { + "add": "Toevoegen", + "remove": "Verwijderen", + "set": "Instellen" + }, + + "typeahead": { + "placeholder": "Zoeken...", + "aria": { + "clear": "Selectie wissen", + "lock": "Veld vergrendelen", + "unlock": "Veld ontgrendelen", + "removeItem": "{{value}} verwijderen", + "results": "Zoekresultaten" + }, + "button": { + "add": "\"{{value}}\" toevoegen" + }, + "text": { + "noResults": "Geen resultaten gevonden" + } + }, + "filter-field-pipe": { + "delivery-state": "Leveringsstatus", + "from": "Van", + "recipient": "Ontvanger", + "lines": "Regels", + "products": "Producten", + "created": "Aangemaakt", + "last-modified": "Laatst Gewijzigd", + "unknown": "Onbekend" + }, + "filter-comparison-pipe": { + "contains": "Bevat", + "not-contains": "Bevat niet", + "equals": "Is gelijk aan", + "not-equals": "Is niet gelijk aan", + "starts-with": "Begint met", + "ends-with": "Eindigt met", + "greater-than": "Groter dan", + "greater-than-or-equals": "Groter dan of gelijk aan", + "less-than": "Kleiner dan", + "less-than-or-equals": "Kleiner dan of gelijk aan", + "unknown": "Onbekend" + }, + "delivery-state-pipe": { + "in-progress": "In Behandeling", + "completed": "Voltooid", + "handled": "Afgehandeld", + "cancelled": "Geannuleerd", + "unknown": "Onbekend" + }, + "sort-field-pipe": { + "from": "Van", + "recipient": "Ontvanger", + "creation-date": "Aanmaakdatum" + }, + + "navigation": { + "items": { + "dashboard": "Dashboard", + "new-delivery": "Nieuwe levering", + "deliveries": "Leveringen bekijken", + "stock": "Voorraad", + "management": "Beheer", + "user": "Gebruiker", + "logout": "Uitloggen" + }, + "management": { + "items": { + "overview": "Overzicht", + "products": "Producten", + "clients": "Klanten", + "deliveries": "Leveringen", + "server": "Server" + } + } + }, + + "management": { + "products": { + "title": "Productbeheer", + "products-table-label": "Producten", + "categories-table-label": "Categorieën", + "create-category": "Categorie Aanmaken", + "create-first-category": "Uw Eerste Categorie Aanmaken", + "create-product": "Product Aanmaken", + "create-first-product": "Uw Eerste Product Aanmaken", + "no-categories": { + "title": "Geen Categorieën Beschikbaar", + "message": "U moet minstens één categorie aanmaken voordat u producten kunt toevoegen. Categorieën helpen uw producten te organiseren en maken ze gemakkelijker te beheren." + }, + "no-products": { + "title": "Geen Producten Gevonden", + "message": "Uw productcatalogus is leeg. Begin met het opbouwen van uw voorraad door uw eerste product aan te maken." + }, + "confirm-delete-product": "Weet u zeker dat u {{name}} wilt verwijderen?", + "destructive-actions": "Deze actie is destructief en kan niet ongedaan worden gemaakt!", + "confirm-delete-category": "Weet u zeker dat u {{name}} wilt verwijderen?", + "confirm-delete-category-re-assign": "{{products}} Product wordt opnieuw toegewezen aan de categorie \"{{newCategory}}\"", + "table": { + "header": { + "name": "Naam", + "description": "Beschrijving", + "category": "Categorie", + "actions": "Acties" + } + }, + "categories": { + "table": { + "header": { + "name": "Naam", + "enabled": "Ingeschakeld", + "size": "Aantal producten in deze categorie", + "actions": "Acties" + }, + "cell": { + "item": "{{count}} producten" + } + } + } + }, + "clients": { + "title": "Klantenbeheer", + "create-client": "Klant aanmaken", + "import-clients": "Klanten importeren", + "no-clients": { + "title": "Geen klanten gevonden", + "message": "Importeer of maak van tevoren klanten aan om tijd te besparen bij het maken van leveringen in de toekomst!", + "btn": "Klanten importeren" + }, + "table": { + "header": { + "name": "Naam", + "companyNumber": "Bedrijfsnummer", + "contactEmail": "E-mail", + "contactNumber": "Telefoonnummer", + "actions": "{{common.actions}}", + "address": "Adres", + "invoiceEmail": "Factuur E-mail", + "contactName": "Contactpersoon" + } + } + }, + "server": { + "title": "Serverbeheer", + "general": "Algemeen", + "export": "Exporteren", + "general-title": "Algemene serverinstellingen", + "log-level-label": "Log niveau", + "log-level-tooltip": "Schakel debug in wanneer er problemen optreden, houd anders hoger om schijfruimte te besparen", + "export-settings": { + "csv-export-title": "CSV Export configuratie", + "available-fields": "Alle beschikbare (ongebruikte) velden", + "selected-fields": "Geselecteerde velden", + "drag-fields-here": "Sleep velden hierheen om uw export aan te passen" + } + }, + "user": { + "title": "Gebruikers voorkeuren", + "language-title": "Taal", + "language-subtitle": "De taal in de web app en foutmeldingen." + } + }, + + "language-pipe": { + "en": "English", + "nl": "Nederlands" + }, + + "product-type-pipe": { + "one-time": "Eenmalig", + "consumable": "Meerdere keren" + }, + + "delivery-export-field-pipe": { + "id": "ID", + "state": "Status", + "from-id": "Afzender ID", + "from": "Afzender Naam", + "recipient-id": "Ontvanger ID", + "recipient-name": "Ontvanger Naam", + "recipient-email": "Ontvanger E-mail", + "company-number": "Bedrijfsnummer", + "message": "Bericht", + "products": "Producten", + "created-utc": "Aanmaakdatum (UTC)", + "last-modified-utc": "Laatst Gewijzigd Datum (UTC)" + }, + + "log-level-pipe": { + "verbose": "Uitgebreid", + "debug": "Debug", + "information": "Informatie", + "warning": "Waarschuwing", + "error": "Fout", + "fatal": "Fataal" + }, + + "product-modal": { + "title": "Product", + "description": "Producten zijn alles wat kan verschijnen in een levering, groepeer ze samen door categorieën te gebruiken", + "create": "{{common.create}}", + "update": "{{common.update}}", + "cancel": "{{common.cancel}}", + "name": "Naam", + "name-tooltip": "De naam moet uniek zijn", + "description-control": "Beschrijving", + "description-tooltip": "Optionele extra informatie over het product om gebruikers te helpen het te identificeren", + "description-default-value": "geen beschrijving ingesteld...", + "category": "Categorie", + "category-tooltip": "Bepaalt hoe het product wordt gegroepeerd in het nieuwe leveringsformulier", + "type": "Producttype", + "type-tooltip": "Hoe vaak kan het product worden gekocht per levering", + "is-tracked": "Product volgen in voorraad", + "is-tracked-tooltip": "Indien uitgeschakeld, wordt het product niet langer gevolgd in voorraad. U moet de huidige hoeveelheid handmatig verwijderen indien van toepassing", + "enabled": "Ingeschakeld", + "enabled-tooltip": "Het product kan worden gebruikt" + }, + + "product-category-modal": { + "title": "Productcategorie", + "description": "Categorieën groeperen verschillende items samen en geven enige ordening aan uw leveringsformulier. Het wordt aanbevolen om ze niet te groot, noch te klein te maken.", + "first-run": "U moet minstens één categorie hebben voordat u producten kunt beginnen aan te maken", + "create": "{{common.create}}", + "update": "{{common.update}}", + "cancel": "{{common.cancel}}", + "close": "{{common.close}}", + "name": "Naam", + "name-tooltip": "Naam weergegeven in het nieuwe leveringsformulier, moet uniek zijn", + "enabled": "Ingeschakeld", + "enabled-tooltip": "Wanneer een categorie is uitgeschakeld, worden alle producten erin ook uitgeschakeld", + "disabled": "Uitgeschakeld", + "auto-collapse": "Automatisch inklappen", + "auto-collapse-tooltip": "Indien ingeschakeld, worden producten in deze categorie standaard verborgen in het nieuwe leveringsformulier" + }, + + "sort-product-category-modal": { + "title": "Producten sorteren binnen {{category}}", + "create": "{{common.create}}", + "update": "{{common.update}}", + "cancel": "{{common.cancel}}", + "close": "{{common.close}}", + "header-name": "Naam" + } +} diff --git a/UI/Web/src/app/_models/product.ts b/UI/Web/src/app/_models/product.ts index f488387..23cf723 100644 --- a/UI/Web/src/app/_models/product.ts +++ b/UI/Web/src/app/_models/product.ts @@ -3,6 +3,7 @@ export type Product = { name: string; description: string; categoryId: number; + sortValue: number; type: ProductType; isTracked: boolean; enabled: boolean; diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 60e5380..32d676b 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -3,5 +3,8 @@ import {Role} from '../_services/auth.service'; export type User = { id: number, name: string, + language: string, roles: Role[], } + +export const AllLanguages = ['en', 'nl']; diff --git a/UI/Web/src/app/_pipes/language-pipe.ts b/UI/Web/src/app/_pipes/language-pipe.ts new file mode 100644 index 0000000..bf29fec --- /dev/null +++ b/UI/Web/src/app/_pipes/language-pipe.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {AllLanguages} from '../_models/user'; +import {translate} from '@jsverse/transloco'; + +@Pipe({ + name: 'language' +}) +export class LanguagePipe implements PipeTransform { + + transform(value: string): string { + if (!AllLanguages.includes(value)) return translate('language-pipe.nal') + + switch (value.toLowerCase()) { + case 'en': + return translate('language-pipe.en') + case 'nl': + return translate('language-pipe.nl') + } + + return translate(value) + } + +} diff --git a/UI/Web/src/app/_services/auth.service.ts b/UI/Web/src/app/_services/auth.service.ts index 59c50d5..fe7882f 100644 --- a/UI/Web/src/app/_services/auth.service.ts +++ b/UI/Web/src/app/_services/auth.service.ts @@ -1,9 +1,10 @@ -import {computed, inject, Injectable, Signal, signal} from '@angular/core'; +import {computed, effect, inject, Injectable, Signal, signal} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {environment} from '../../environments/environment'; import {toObservable} from '@angular/core/rxjs-interop'; import {catchError, map, of, switchMap, tap} from 'rxjs'; -import {User} from '../_models/user'; +import {AllLanguages, User} from '../_models/user'; +import {TranslocoService} from '@jsverse/transloco'; export enum Role { CreateForOthers = 'CreateForOthers', @@ -16,16 +17,13 @@ export enum Role { ManageApplication = 'ManageApplication', } -export type UserInfo = { - UserName: string; -} - @Injectable({ providedIn: 'root' }) export class AuthService { private readonly httpClient = inject(HttpClient); + private readonly transLoco = inject(TranslocoService); private readonly baseUrl = environment.apiUrl; @@ -34,20 +32,31 @@ export class AuthService { public readonly loaded$ = toObservable(this.loaded); public readonly roles: Signal = computed(() => { - const userInfo = this.userInfo(); + const userInfo = this.user(); if (!userInfo) return []; return userInfo.roles; }); public readonly isAuthenticated= computed((): boolean => { - return this.userInfo() !== undefined; + return this.user() !== null; }); - private readonly _userInfo = signal(undefined); - public readonly userInfo = this._userInfo.asReadonly(); + // There is always a user, as we load it before the app inits + private readonly _user = signal(null!); + public readonly user = this._user.asReadonly(); constructor() { + effect(() => { + const user = this._user(); + if (user == null) return; + + const language = user.language || 'en'; + if (!AllLanguages.includes(language)) return; + + this.transLoco.setActiveLang(language); + this.transLoco.load(language).subscribe(); + }); } loadUser() { @@ -60,7 +69,7 @@ export class AuthService { return this.httpClient.get(this.baseUrl + 'user/').pipe( tap(user => { - this._userInfo.set(user); + this._user.set(user); this._loaded.set(true); }), map(() => true), @@ -73,10 +82,10 @@ export class AuthService { window.location.href = "/Auth/logout"; } - private decodeJwt(token: string) { - const payload = token.split('.')[1]; - const decoded = atob(payload); - return JSON.parse(decoded); + update(user: User) { + return this.httpClient.post(`${this.baseUrl}user/`, user).pipe( + tap(user => this._user.set(user)), + ) } } diff --git a/UI/Web/src/app/_services/navigation.service.ts b/UI/Web/src/app/_services/navigation.service.ts index 948a9fb..13ab720 100644 --- a/UI/Web/src/app/_services/navigation.service.ts +++ b/UI/Web/src/app/_services/navigation.service.ts @@ -2,7 +2,6 @@ import {computed, inject, Injectable, signal} from '@angular/core'; import {AuthService, Role} from './auth.service'; export enum ManagementSettingsId { - Overview = 'overview', Products = 'products', Clients = 'clients', Server= 'server', @@ -13,7 +12,7 @@ export enum NavigationsId { NewDelivery = 'newDelivery', Deliveries = 'deliveries', Stock = 'stock', - Management = 'management', + User = 'user', Logout = 'logout', } @@ -100,6 +99,13 @@ export class NavigationService { requiredRoles: [Role.ManageApplication], routerLink: '/management/server' }, + { + id: NavigationsId.User, + translationKey: 'navigation.items.user', + icon: 'fas fa-user', + requiredRoles: [], + routerLink: '/management/user' + }, { id: NavigationsId.Logout, translationKey: 'navigation.items.logout', diff --git a/UI/Web/src/app/_services/product.service.ts b/UI/Web/src/app/_services/product.service.ts index a723593..399a093 100644 --- a/UI/Web/src/app/_services/product.service.ts +++ b/UI/Web/src/app/_services/product.service.ts @@ -58,4 +58,8 @@ export class ProductService { orderCategories(ids: number[]) { return this.http.post(`${this.baseUrl}category/order`, ids); } + + orderProducts(ids: number[]) { + return this.http.post(`${this.baseUrl}order`, ids); + } } diff --git a/UI/Web/src/app/app.config.ts b/UI/Web/src/app/app.config.ts index d29022d..09e8405 100644 --- a/UI/Web/src/app/app.config.ts +++ b/UI/Web/src/app/app.config.ts @@ -11,12 +11,13 @@ import {HTTP_INTERCEPTORS, provideHttpClient, withFetch, withInterceptorsFromDi} import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; import {TranslocoHttpLoader} from './_services/transloco-loader'; -import {provideTransloco} from '@jsverse/transloco'; +import {provideTransloco, TranslocoService} from '@jsverse/transloco'; import {provideToastr} from 'ngx-toastr'; import {ErrorInterceptor} from './_interceptors/error-interceptor'; import {DeliveryStatePipe} from './_pipes/delivery-state-pipe'; import {AuthService} from './_services/auth.service'; -import {firstValueFrom} from 'rxjs'; +import {filter, firstValueFrom, map, of, switchMap, tap} from 'rxjs'; +import {AllLanguages} from './_models/user'; export const appConfig: ApplicationConfig = { providers: [ @@ -32,7 +33,7 @@ export const appConfig: ApplicationConfig = { provideAnimationsAsync(), provideTransloco({ config: { - availableLangs: ['en'], + availableLangs: AllLanguages, defaultLang: 'en', missingHandler: { useFallbackTranslation: true, @@ -43,14 +44,15 @@ export const appConfig: ApplicationConfig = { }, loader: TranslocoHttpLoader, }), - provideAppInitializer(async () => { + provideAppInitializer(() => { const authService = inject(AuthService); - const loggedIn = await firstValueFrom(authService.loadUser()); - if (!loggedIn) { - window.location.href = 'Auth/login'; - } - - return Promise.resolve(); + return firstValueFrom(authService.loadUser().pipe( + tap(loggedIn => { + if (!loggedIn) { + authService.logout(); + } + }), + )); }), ] }; diff --git a/UI/Web/src/app/app.routes.ts b/UI/Web/src/app/app.routes.ts index e15adcc..a1a07cd 100644 --- a/UI/Web/src/app/app.routes.ts +++ b/UI/Web/src/app/app.routes.ts @@ -9,6 +9,7 @@ import {BrowseStockComponent} from './browse-stock/browse-stock.component'; import {ManagementProductsComponent} from './management/management-products/management-products.component'; import {ManagementClientsComponent} from './management/management-clients/management-clients.component'; import {ManagementServerComponent} from './management/management-server/management-server.component'; +import {ManageUserComponent} from './management/manage-user/manage-user.component'; export const routes: Routes = [ { @@ -65,6 +66,10 @@ export const routes: Routes = [ path: 'server', component: ManagementServerComponent, canActivate: [roleGuard(Role.ManageApplication)], + }, + { + path: 'user', + component: ManageUserComponent, } ], }, diff --git a/UI/Web/src/app/dashboard/dashboard.component.html b/UI/Web/src/app/dashboard/dashboard.component.html index d836aa7..6bd4fc8 100644 --- a/UI/Web/src/app/dashboard/dashboard.component.html +++ b/UI/Web/src/app/dashboard/dashboard.component.html @@ -1,5 +1,5 @@