From 8a1e370127b44c3670d5f25ecbaff6a5e3061509 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:58:24 +0200 Subject: [PATCH 1/5] BVDK-52: Fix products layout on mobile --- .../manage-delivery.component.html | 15 +++++++++------ .../manage-delivery.component.scss | 2 -- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.html b/UI/Web/src/app/manage-delivery/manage-delivery.component.html index c5cc1fe..ba2f5a9 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.html +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.html @@ -112,7 +112,8 @@
-
+ +
{{ product.name }} @@ -161,11 +162,13 @@
} @else { -
- +
+
+ +
} diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.scss b/UI/Web/src/app/manage-delivery/manage-delivery.component.scss index 9af9a1a..ef239bd 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.scss +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.scss @@ -75,8 +75,6 @@ @media (max-width: 768px) { .product-item { - flex-direction: column; - align-items: flex-start !important; gap: 12px; .product-info { From 4e2f99f2ad3bafc399983e32075e5a85c6d1a7cd Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Fri, 5 Sep 2025 17:07:47 +0200 Subject: [PATCH 2/5] BVDK-51: Remove half working dark mode --- .../app/dashboard/dashboard.component.scss | 16 ----------- UI/Web/src/theme/components/input.scss | 27 ------------------- 2 files changed, 43 deletions(-) diff --git a/UI/Web/src/app/dashboard/dashboard.component.scss b/UI/Web/src/app/dashboard/dashboard.component.scss index 0ed6a10..0de8901 100644 --- a/UI/Web/src/app/dashboard/dashboard.component.scss +++ b/UI/Web/src/app/dashboard/dashboard.component.scss @@ -170,19 +170,3 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } - -// Dark mode support (optional) -@media (prefers-color-scheme: dark) { - .nav-button { - background: var(--primary-color-light); - border-color: var(--primary-color-lighter); - - &:hover { - background: var(--primary-color); - } - } - - .nav-button-text { - color: var(--text-light); - } -} diff --git a/UI/Web/src/theme/components/input.scss b/UI/Web/src/theme/components/input.scss index e0a0e0f..94ed27a 100644 --- a/UI/Web/src/theme/components/input.scss +++ b/UI/Web/src/theme/components/input.scss @@ -312,30 +312,3 @@ textarea.form-control { outline: 2px solid var(--secondary-color); outline-offset: 2px; } - -@media (prefers-color-scheme: dark) { - .form-control, - .form-select { - background-color: var(--primary-color-light); - border-color: var(--primary-color-lighter); - color: var(--text-light); - - &::placeholder { - color: rgba(248, 246, 243, 0.7); - } - - &:focus { - background-color: var(--primary-color-light); - border-color: var(--secondary-color); - } - } - - .view-value { - background-color: var(--primary-color-light); - color: var(--text-light); - - &:hover:not(.non-selectable) { - background-color: var(--primary-color-lighter); - } - } -} From 38bc836eff71375689cbbc3bd8f6c586bb4ca5ba Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Fri, 5 Sep 2025 17:35:26 +0200 Subject: [PATCH 3/5] BVDK-50.1: Sort products inside category backend --- API/Controllers/ProductsController.cs | 13 + API/DTOs/ProductDto.cs | 1 + API/Data/Repositories/ProductRepository.cs | 8 + API/Entities/Product.cs | 4 + .../ManualMigrationAddProductSortValues.cs | 49 ++ ...ualMigrationAddStockForExistingProducts.cs | 2 +- ...0250905152739_ProductSortValue.Designer.cs | 436 ++++++++++++++++++ .../20250905152739_ProductSortValue.cs | 29 ++ API/Migrations/DataContextModelSnapshot.cs | 3 + API/Program.cs | 8 +- API/Services/ProductService.cs | 57 ++- API/Startup.cs | 1 + UI/Web/public/assets/i18n/en.json | 4 +- UI/Web/src/app/_models/product.ts | 1 + .../manage-delivery.component.ts | 1 + .../product-modal/product-modal.component.ts | 2 + 16 files changed, 611 insertions(+), 8 deletions(-) create mode 100644 API/ManualMigrations/ManualMigrationAddProductSortValues.cs create mode 100644 API/Migrations/20250905152739_ProductSortValue.Designer.cs create mode 100644 API/Migrations/20250905152739_ProductSortValue.cs diff --git a/API/Controllers/ProductsController.cs b/API/Controllers/ProductsController.cs index accf817..8708990 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("products/order")] + [Authorize(Policy = PolicyConstants.ManageProducts)] + public async Task OrderProducts(IList ids) + { + await productService.OrderProducts(ids); + return Ok(); + } } 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/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/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/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..2ab7e8d 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -24,7 +24,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": { 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/manage-delivery/manage-delivery.component.ts b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts index a40f0e6..1dbfec3 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts @@ -59,6 +59,7 @@ export class ManageDeliveryComponent implements OnInit { products.forEach(p => { let slice = map.get(p.categoryId) || []; slice.push(p); + slice.sort((a, b) => a.sortValue - b.sortValue); map.set(p.categoryId, slice); }); return map; diff --git a/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts b/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts index 23ef317..71ae5ec 100644 --- a/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts +++ b/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts @@ -37,6 +37,7 @@ export class ProductModalComponent implements OnInit { type: ProductType.Consumable, enabled: true, isTracked: true, + sortValue: 0, }); isSaving = signal(false); @@ -76,6 +77,7 @@ export class ProductModalComponent implements OnInit { categoryId: formValue.category, enabled: formValue.enabled, isTracked: formValue.isTracked, + sortValue: this.product().sortValue, } const action$ = id === -1 From 55d66c5f80fbe8b6587f825ead152786ec2fc84b Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 6 Sep 2025 17:38:45 +0200 Subject: [PATCH 4/5] BVDK-50.2: Sort products inside category UI --- API/Controllers/ProductsController.cs | 2 +- UI/Web/public/assets/i18n/en.json | 14 ++--- UI/Web/src/app/_services/product.service.ts | 4 ++ .../sort-products-modal.component.html | 50 +++++++++++++++++ .../sort-products-modal.component.scss | 9 +++ .../sort-products-modal.component.ts | 55 +++++++++++++++++++ .../management-products.component.html | 7 +++ .../management-products.component.ts | 12 ++++ .../components/table/table.component.html | 2 +- .../components/table/table.component.scss | 4 ++ .../components/table/table.component.ts | 3 + 11 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.html create mode 100644 UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.scss create mode 100644 UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.ts diff --git a/API/Controllers/ProductsController.cs b/API/Controllers/ProductsController.cs index 8708990..72ff1b2 100644 --- a/API/Controllers/ProductsController.cs +++ b/API/Controllers/ProductsController.cs @@ -162,7 +162,7 @@ public async Task OrderCategories(IList ids) /// /// /// - [HttpPost("products/order")] + [HttpPost("order")] [Authorize(Policy = PolicyConstants.ManageProducts)] public async Task OrderProducts(IList ids) { diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index 2ab7e8d..b3fdc73 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -447,12 +447,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/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/management/management-products/_components/sort-products-modal/sort-products-modal.component.html b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.html new file mode 100644 index 0000000..a067c1e --- /dev/null +++ b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.html @@ -0,0 +1,50 @@ + + + diff --git a/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.scss b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.scss new file mode 100644 index 0000000..14d61f0 --- /dev/null +++ b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.scss @@ -0,0 +1,9 @@ +.drag-column { + width: 40px !important; + min-width: 40px; + max-width: 40px; +} + +.name-column { + width: auto; +} diff --git a/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.ts b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.ts new file mode 100644 index 0000000..14710d7 --- /dev/null +++ b/UI/Web/src/app/management/management-products/_components/sort-products-modal/sort-products-modal.component.ts @@ -0,0 +1,55 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model, signal} from '@angular/core'; +import {LoadingSpinnerComponent} from '../../../../shared/components/loading-spinner/loading-spinner.component'; +import {ProductService} from '../../../../_services/product.service'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {Product, ProductCategory} from '../../../../_models/product'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {TableComponent} from '../../../../shared/components/table/table.component'; +import {CdkDragDrop, CdkDragHandle, moveItemInArray} from '@angular/cdk/drag-drop'; +import {ToastrService} from 'ngx-toastr'; + +@Component({ + selector: 'app-sort-products-modal', + imports: [ + LoadingSpinnerComponent, + TranslocoDirective, + TableComponent, + CdkDragHandle + ], + templateUrl: './sort-products-modal.component.html', + styleUrl: './sort-products-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SortProductsModalComponent { + + private readonly productService = inject(ProductService); + private readonly toastR = inject(ToastrService); + protected readonly modal = inject(NgbActiveModal); + + isSaving = signal(false); + category = model.required(); + products = model.required() + + trackById(index: number, p: Product) { + return `${p.id}` + } + + close() { + this.modal.close(); + } + + save() { + const ids = this.products().map((product) => product.id); + this.productService.orderProducts(ids).subscribe({ + error: err => { + this.toastR.error(err.message); + } + }).add(() => this.close()); + } + + onProductDrop($event: CdkDragDrop) { + const current = [...this.products()]; // We require a copy as moveItemInArray moves in place + moveItemInArray(current, $event.previousIndex, $event.currentIndex); + this.products.set(current); + } +} diff --git a/UI/Web/src/app/management/management-products/management-products.component.html b/UI/Web/src/app/management/management-products/management-products.component.html index 7fff3a1..40bce4f 100644 --- a/UI/Web/src/app/management/management-products/management-products.component.html +++ b/UI/Web/src/app/management/management-products/management-products.component.html @@ -163,6 +163,13 @@
{{t('categories-table-label')}}
> +