Add support for item categories in registries

Introduced `RegistryItemCategory` entity for grouping and ordering items within registries. Updated `RegistryItem` and `Registry` entities to support categorization. Added database migrations for `RegistryItemCategories` and updated `RegistryItems` with `CategoryId` and `SortOrder`.

Implemented drag-and-drop functionality for reordering categories and items using JavaScript and Blazor. Enhanced `RegistryAdmin` and `RegistryPublic` components to manage and display categories with collapsible sections.

Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
This commit is contained in:
Arne Moerman
2026-05-19 17:02:31 +02:00
parent fa704ab996
commit 09349cb7b7
21 changed files with 2334 additions and 252 deletions
@@ -15,6 +15,7 @@ public class Registry
public DateTimeOffset CreatedAtUtc { get; set; }
public ICollection<RegistryAdmin> Admins { get; set; } = [];
public ICollection<RegistryItemCategory> ItemCategories { get; set; } = [];
public ICollection<RegistryItem> Items { get; set; } = [];
public ICollection<RegistryVisit> Visits { get; set; } = [];
public ICollection<RegistryAdminInvite> AdminInvites { get; set; } = [];
@@ -4,6 +4,8 @@ public class RegistryItem
{
public Guid Id { get; set; }
public Guid RegistryId { get; set; }
public Guid CategoryId { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = string.Empty;
public string? PictureUrl { get; set; }
public string? ProductUrl { get; set; }
@@ -20,6 +22,7 @@ public class RegistryItem
public DateTimeOffset CreatedAtUtc { get; set; }
public Registry Registry { get; set; } = null!;
public RegistryItemCategory Category { get; set; } = null!;
public ICollection<ItemPurchase> Purchases { get; set; } = [];
public ICollection<ItemContribution> Contributions { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace BirthList.Domain.Entities;
public class RegistryItemCategory
{
public Guid Id { get; set; }
public Guid RegistryId { get; set; }
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Registry Registry { get; set; } = null!;
public ICollection<RegistryItem> Items { get; set; } = [];
}
@@ -15,10 +15,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.26" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,552 @@
// <auto-generated />
using System;
using BirthList.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
[DbContext(typeof(RegistryDbContext))]
[Migration("20260519124946_AddRegistryItemCategoriesAndOrdering")]
partial class AddRegistryItemCategoriesAndOrdering
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.26")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BirthList.Domain.Entities.ItemContribution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("ContributedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<Guid>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TransferMessage")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryItemId");
b.ToTable("ItemContributions");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("PurchasedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<Guid>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryItemId");
b.ToTable("ItemPurchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.PlatformOwner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AssignedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("PlatformOwners");
});
modelBuilder.Entity("BirthList.Domain.Entities.Registry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BabyName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateOnly?>("BirthDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<string>("HeaderContentHtml")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicLinkCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("RegistryType")
.HasColumnType("int");
b.Property<string>("ShippingAddress")
.HasColumnType("nvarchar(max)");
b.Property<string>("ThemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.HasKey("Id");
b.HasIndex("PublicLinkCode")
.IsUnique();
b.ToTable("Registries");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("AddedAtUtc")
.HasColumnType("datetimeoffset");
b.HasKey("RegistryId", "UserId");
b.ToTable("RegistryAdmins");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("RedeemedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SentToEmail")
.HasMaxLength(320)
.HasColumnType("nvarchar(320)");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("Token")
.IsUnique();
b.ToTable("RegistryAdminInvites");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CategoryId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int>("DesiredQuantity")
.HasColumnType("int");
b.Property<bool>("IsGiven")
.HasColumnType("bit");
b.Property<decimal>("MoneyFulfilledAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("ParticipationAllowed")
.HasColumnType("bit");
b.Property<decimal?>("ParticipationTargetAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("PictureUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool?>("PreferSecondHand")
.HasColumnType("bit");
b.Property<decimal?>("PriceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ProductUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("PurchasedQuantity")
.HasColumnType("int");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("BankAccountBic")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("BankAccountDisplayName")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("BankAccountIban")
.HasMaxLength(34)
.HasColumnType("nvarchar(34)");
b.Property<string>("ContributionAmountQrCodesJson")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("ContributionQrCodeUrl")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("ShowBankAccountName")
.HasColumnType("bit");
b.HasKey("RegistryId");
b.ToTable("RegistrySettings");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b =>
{
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("LastVisitedAtUtc")
.HasColumnType("datetimeoffset");
b.HasKey("RegistryId", "UserId");
b.ToTable("RegistryVisits");
});
modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ActionType")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Details")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("RegistryItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.ToTable("UserActionLogs");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemContribution", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem")
.WithMany("Contributions")
.HasForeignKey("RegistryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RegistryItem");
});
modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem")
.WithMany("Purchases")
.HasForeignKey("RegistryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RegistryItem");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Admins")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("AdminInvites")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithOne()
.HasForeignKey("BirthList.Domain.Entities.RegistrySettings", "RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Visits")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ActionLogs")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.Registry", b =>
{
b.Navigation("ActionLogs");
b.Navigation("AdminInvites");
b.Navigation("Admins");
b.Navigation("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRegistryItemCategoriesAndOrdering : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CategoryId",
table: "RegistryItems",
type: "uniqueidentifier",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<int>(
name: "SortOrder",
table: "RegistryItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "RegistryItemCategories",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryItemCategories", x => x.Id);
table.ForeignKey(
name: "FK_RegistryItemCategories_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql(
"""
INSERT INTO RegistryItemCategories (Id, RegistryId, Name, SortOrder, CreatedAtUtc)
SELECT NEWID(), r.Id, N'General', 0, SYSUTCDATETIME()
FROM Registries r
WHERE NOT EXISTS (
SELECT 1
FROM RegistryItemCategories c
WHERE c.RegistryId = r.Id
);
;WITH GeneralCategories AS (
SELECT c.RegistryId, c.Id AS CategoryId
FROM RegistryItemCategories c
INNER JOIN (
SELECT RegistryId, MIN(SortOrder) AS MinSortOrder
FROM RegistryItemCategories
GROUP BY RegistryId
) firstCategory ON firstCategory.RegistryId = c.RegistryId AND firstCategory.MinSortOrder = c.SortOrder
), OrderedItems AS (
SELECT i.Id AS ItemId,
gc.CategoryId,
ROW_NUMBER() OVER (PARTITION BY i.RegistryId ORDER BY i.CreatedAtUtc, i.Id) - 1 AS SortOrder
FROM RegistryItems i
INNER JOIN GeneralCategories gc ON gc.RegistryId = i.RegistryId
)
UPDATE i
SET i.CategoryId = oi.CategoryId,
i.SortOrder = oi.SortOrder
FROM RegistryItems i
INNER JOIN OrderedItems oi ON oi.ItemId = i.Id;
""");
migrationBuilder.CreateIndex(
name: "IX_RegistryItems_CategoryId_SortOrder",
table: "RegistryItems",
columns: new[] { "CategoryId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_RegistryItemCategories_RegistryId_Name",
table: "RegistryItemCategories",
columns: new[] { "RegistryId", "Name" });
migrationBuilder.CreateIndex(
name: "IX_RegistryItemCategories_RegistryId_SortOrder",
table: "RegistryItemCategories",
columns: new[] { "RegistryId", "SortOrder" });
migrationBuilder.AddForeignKey(
name: "FK_RegistryItems_RegistryItemCategories_CategoryId",
table: "RegistryItems",
column: "CategoryId",
principalTable: "RegistryItemCategories",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RegistryItems_RegistryItemCategories_CategoryId",
table: "RegistryItems");
migrationBuilder.DropTable(
name: "RegistryItemCategories");
migrationBuilder.DropIndex(
name: "IX_RegistryItems_CategoryId_SortOrder",
table: "RegistryItems");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "RegistryItems");
migrationBuilder.DropColumn(
name: "SortOrder",
table: "RegistryItems");
}
}
}
@@ -221,6 +221,9 @@ namespace BirthList.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CategoryId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
@@ -275,13 +278,47 @@ namespace BirthList.Infrastructure.Migrations
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.Property<Guid>("RegistryId")
@@ -419,12 +456,31 @@ namespace BirthList.Infrastructure.Migrations
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
@@ -469,6 +525,8 @@ namespace BirthList.Infrastructure.Migrations
b.Navigation("Admins");
b.Navigation("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
@@ -480,6 +538,11 @@ namespace BirthList.Infrastructure.Migrations
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
@@ -9,6 +9,7 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
public DbSet<RegistryAdmin> RegistryAdmins => Set<RegistryAdmin>();
public DbSet<RegistryAdminInvite> RegistryAdminInvites => Set<RegistryAdminInvite>();
public DbSet<RegistrySettings> RegistrySettings => Set<RegistrySettings>();
public DbSet<RegistryItemCategory> RegistryItemCategories => Set<RegistryItemCategory>();
public DbSet<RegistryItem> RegistryItems => Set<RegistryItem>();
public DbSet<ItemPurchase> ItemPurchases => Set<ItemPurchase>();
public DbSet<ItemContribution> ItemContributions => Set<ItemContribution>();
@@ -65,6 +66,18 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RegistryItemCategory>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).HasMaxLength(100);
entity.HasIndex(x => new { x.RegistryId, x.SortOrder });
entity.HasIndex(x => new { x.RegistryId, x.Name });
entity.HasOne(x => x.Registry)
.WithMany(x => x.ItemCategories)
.HasForeignKey(x => x.RegistryId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RegistryItem>(entity =>
{
entity.HasKey(x => x.Id);
@@ -75,10 +88,15 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
entity.Property(x => x.PriceAmount).HasPrecision(18, 2);
entity.Property(x => x.ParticipationTargetAmount).HasPrecision(18, 2);
entity.Property(x => x.MoneyFulfilledAmount).HasPrecision(18, 2);
entity.HasIndex(x => new { x.CategoryId, x.SortOrder });
entity.HasOne(x => x.Registry)
.WithMany(x => x.Items)
.HasForeignKey(x => x.RegistryId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Category)
.WithMany(x => x.Items)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.NoAction);
});
modelBuilder.Entity<ItemPurchase>(entity =>
+4
View File
@@ -30,4 +30,8 @@
<ProjectReference Include="..\BirthList.Domain\BirthList.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\Pages\Partials\" />
</ItemGroup>
</Project>
@@ -73,9 +73,11 @@
private async Task OnValidSubmitAsync()
{
if (Input.PhoneNumber != phoneNumber)
var normalizedPhone = string.IsNullOrWhiteSpace(Input.PhoneNumber) ? null : Input.PhoneNumber.Trim();
if (normalizedPhone != phoneNumber)
{
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, normalizedPhone);
if (!setPhoneResult.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext);
@@ -110,8 +112,28 @@
[Display(Name = "Address")]
public string? Address { get; set; }
[Phone]
[OptionalPhone(ErrorMessage = "The Phone number field is not a valid phone number.")]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
}
private sealed class OptionalPhoneAttribute : ValidationAttribute
{
private static readonly PhoneAttribute PhoneValidator = new();
public override bool IsValid(object? value)
{
if (value is null)
{
return true;
}
if (value is string stringValue && string.IsNullOrWhiteSpace(stringValue))
{
return true;
}
return PhoneValidator.IsValid(value);
}
}
}
+1
View File
@@ -20,6 +20,7 @@
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<script src="_content/Blazored.TextEditor/quill-blot-formatter.min.js"></script>
<script src="_content/Blazored.TextEditor/Blazored-BlazorQuill.js"></script>
<script src="js/drag-drop.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -54,6 +54,17 @@ else
<span class="bi bi-people"></span> Administrators
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("addresses"))"
id="addresses-tab"
@onclick='() => SetActiveTab("addresses")'
type="button"
role="tab"
aria-controls="addresses-content"
aria-selected="@(ActiveTab == "addresses" ? "true" : "false")">
<span class="bi bi-house"></span> Addresses
</button>
</li>
<li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
<span class="bi bi-clock-history"></span> Action Log
@@ -119,64 +130,162 @@ else
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<InputSelect class="form-select" @bind-Value="ItemModel.CategoryId">
@foreach (var category in ItemCategories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
</section>
<section>
<h2>Items</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
<h2 class="mb-0">Categories and items</h2>
<div class="d-flex gap-2">
<InputText class="form-control" @bind-Value="NewCategoryName" placeholder="New category" />
<button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">Add category</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(ItemManagementMessage))
{
<div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div>
}
<p class="text-muted mt-3 mb-2">Drag categories or items to reorder. Drop items into another category to regroup them.</p>
<div class="category-groups mt-3">
@foreach (var category in ItemCategories)
{
<section id="category-card-@category.Id"
class="card category-group @GetCategoryDropClass(category.Id)"
draggable="true"
@ondragstart="() => OnCategoryDragStart(category.Id)"
@ondragend="OnCategoryDragEnd"
@ondragover="@(args => OnCategoryDragOverAsync(category.Id, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryDropAsync(category.Id)">
<div class="card-header d-flex justify-content-between align-items-center">
@if (EditingCategoryId == category.Id)
{
<div class="d-flex gap-2 align-items-center w-100">
<InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" />
<button type="button" class="btn btn-sm btn-primary" @onclick="() => SaveCategoryRenameAsync(category.Id)">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelCategoryRename">Cancel</button>
</div>
}
else
{
<strong>@category.Name</strong>
<div class="d-flex gap-2">
<button type="button"
class="btn btn-sm btn-outline-secondary"
@onclick="() => StartCategoryRename(category)">
Rename
</button>
<button type="button"
class="btn btn-sm btn-outline-danger"
@onclick="() => RemoveCategoryAsync(category.Id)"
disabled="@(ItemCategories.Count <= 1)">
Remove
</button>
</div>
}
</div>
<div class="card-body p-0 @GetCategoryItemsDropClass(category.Id)"
@ondragover="() => OnCategoryItemsDragOver(category.Id)"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryItemsDropAsync(category.Id)">
@if (category.Items.Count == 0)
{
<div class="p-3 text-muted">Drop items here.</div>
}
else
{
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in category.Items)
{
<tr id="item-row-@item.Id"
class="@GetItemDropClass(category.Id, item.Id!.Value)"
draggable="true"
@ondragstart:stopPropagation="true"
@ondragstart="() => OnItemDragStart(item.Id!.Value)"
@ondragend="OnItemDragEnd"
@ondragover:stopPropagation="true"
@ondragover="@(args => OnItemDragOverAsync(category.Id, item.Id!.Value, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)">
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</section>
}
<div class="category-list-end-drop-zone @GetCategoryListEndDropClass()"
@ondragover="OnCategoryListEndDragOver"
@ondragover:preventDefault="true"
@ondrop:preventDefault="true"
@ondrop="OnCategoryListEndDropAsync">
</div>
</div>
</section>
</div>
@@ -296,6 +405,39 @@ else
</section>
</div>
<!-- Addresses Tab -->
<div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab">
<section class="mb-4">
<h2>User addresses</h2>
@if (AccessibleUserAddresses.Count == 0)
{
<p class="text-muted">No users found yet.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@foreach (var user in AccessibleUserAddresses)
{
<tr>
<td>@user.DisplayName</td>
<td>@(string.IsNullOrWhiteSpace(user.Email) ? "-" : user.Email)</td>
<td>@(string.IsNullOrWhiteSpace(user.Address) ? "-" : user.Address)</td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4">
@@ -4,6 +4,7 @@ using BirthList.Web.Services;
using Blazored.TextEditor;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace BirthList.Web.Components.Pages;
@@ -20,11 +21,18 @@ public partial class RegistryAdmin : ComponentBase
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
[Inject] private SmtpEmailSender EmailSender { get; set; } = null!;
[Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
protected RegistrySettingsEditModel SettingsModel { get; } = new();
protected RegistryItemEditModel ItemModel { get; private set; } = new();
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
protected IReadOnlyList<RegistryItemCategoryEditModel> ItemCategories { get; private set; } = [];
protected string? NewCategoryName { get; set; }
protected string? CategoryRenameName { get; set; }
protected Guid? EditingCategoryId { get; set; }
protected string? ItemManagementMessage { get; private set; }
protected IReadOnlyList<RegistryAdminDisplayModel> Admins { get; private set; } = [];
protected IReadOnlyList<RegistryAccessibleUserAddressViewModel> AccessibleUserAddresses { get; private set; } = [];
protected bool IsAuthorized { get; private set; }
protected bool IsSmtpConfigured { get; private set; }
protected string? InviteEmail { get; set; }
@@ -41,6 +49,15 @@ public partial class RegistryAdmin : ComponentBase
private bool _pendingEditorLoad;
private string? _lastLoadedHeaderContentHtml;
private Guid? _draggedItemId;
private Guid? _draggedCategoryId;
private Guid? _itemDropCategoryId;
private Guid? _itemDropTargetItemId;
private bool _itemDropAfterTarget;
private Guid? _categoryDropTargetId;
private bool _categoryDropAfterTarget;
private bool _categoryDropAtEnd;
private bool _dropOperationInProgress;
protected override async Task OnParametersSetAsync()
{
@@ -122,13 +139,22 @@ public partial class RegistryAdmin : ComponentBase
protected async Task SaveItemAsync()
{
ItemManagementMessage = null;
if (!ItemModel.CategoryId.HasValue && ItemCategories.Count > 0)
{
ItemModel.CategoryId = ItemCategories[0].Id;
}
await RegistryService.UpsertRegistryItemAsync(RegistryId, ItemModel, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel = new RegistryItemEditModel
{
CurrencyCode = SettingsModel.CurrencyCode,
DesiredQuantity = 1
DesiredQuantity = 1,
CategoryId = ItemCategories.FirstOrDefault()?.Id
};
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task DeleteItemAsync(Guid? itemId)
@@ -138,15 +164,19 @@ public partial class RegistryAdmin : ComponentBase
return;
}
ItemManagementMessage = null;
await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
}
protected void EditItem(RegistryItemEditModel model)
{
ItemManagementMessage = null;
ItemModel = new RegistryItemEditModel
{
Id = model.Id,
CategoryId = model.CategoryId,
Name = model.Name,
PictureUrl = model.PictureUrl,
ProductUrl = model.ProductUrl,
@@ -161,6 +191,391 @@ public partial class RegistryAdmin : ComponentBase
};
}
protected async Task AddCategoryAsync()
{
ItemManagementMessage = null;
if (string.IsNullOrWhiteSpace(NewCategoryName))
{
ItemManagementMessage = "Category name is required.";
return;
}
try
{
await RegistryService.AddRegistryItemCategoryAsync(RegistryId, NewCategoryName, CancellationToken.None).ConfigureAwait(false);
NewCategoryName = null;
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel.CategoryId ??= ItemCategories.LastOrDefault()?.Id;
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected void StartCategoryRename(RegistryItemCategoryEditModel category)
{
ArgumentNullException.ThrowIfNull(category);
ItemManagementMessage = null;
EditingCategoryId = category.Id;
CategoryRenameName = category.Name;
}
protected void CancelCategoryRename()
{
EditingCategoryId = null;
CategoryRenameName = null;
}
protected async Task SaveCategoryRenameAsync(Guid categoryId)
{
ItemManagementMessage = null;
if (string.IsNullOrWhiteSpace(CategoryRenameName))
{
ItemManagementMessage = "Category name is required.";
return;
}
try
{
await RegistryService.RenameRegistryItemCategoryAsync(RegistryId, categoryId, CategoryRenameName, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
EditingCategoryId = null;
CategoryRenameName = null;
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected async Task RemoveCategoryAsync(Guid categoryId)
{
ItemManagementMessage = null;
if (ItemCategories.Count <= 1)
{
ItemManagementMessage = "At least one category is required.";
return;
}
try
{
await RegistryService.RemoveRegistryItemCategoryAsync(RegistryId, categoryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
if (ItemModel.CategoryId == categoryId)
{
ItemModel.CategoryId = ItemCategories.FirstOrDefault()?.Id;
}
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected void OnItemDragStart(Guid itemId)
{
_draggedItemId = itemId;
_draggedCategoryId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected void OnItemDragEnd()
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
protected void OnCategoryDragStart(Guid categoryId)
{
_draggedCategoryId = categoryId;
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected void OnCategoryDragEnd()
{
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected static void OnDragOver(DragEventArgs _)
{
}
protected async Task OnItemDragOverAsync(Guid categoryId, Guid targetItemId, DragEventArgs args)
{
if (!_draggedItemId.HasValue)
{
return;
}
_itemDropCategoryId = categoryId;
_itemDropTargetItemId = targetItemId;
var isBottomHalf = await IsPointerInBottomHalfAsync($"item-row-{targetItemId}", args.ClientY).ConfigureAwait(false);
_itemDropAfterTarget = isBottomHalf;
await InvokeAsync(StateHasChanged);
}
protected void OnCategoryItemsDragOver(Guid categoryId)
{
if (!_draggedItemId.HasValue)
{
return;
}
var category = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (category is null || category.Items.Count > 0)
{
return;
}
_itemDropCategoryId = categoryId;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
protected async Task OnCategoryDragOverAsync(Guid categoryId, DragEventArgs args)
{
if (!_draggedCategoryId.HasValue)
{
return;
}
_categoryDropTargetId = categoryId;
_categoryDropAfterTarget = await IsPointerInBottomHalfAsync($"category-card-{categoryId}", args.ClientY).ConfigureAwait(false);
_categoryDropAtEnd = false;
await InvokeAsync(StateHasChanged);
}
protected void OnCategoryListEndDragOver()
{
if (!_draggedCategoryId.HasValue)
{
return;
}
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = true;
}
protected string GetItemDropClass(Guid categoryId, Guid itemId)
{
if (_itemDropCategoryId != categoryId || _itemDropTargetItemId != itemId)
{
return string.Empty;
}
return _itemDropAfterTarget ? "drop-target-item-after" : "drop-target-item-before";
}
protected string GetCategoryItemsDropClass(Guid categoryId)
{
return _itemDropCategoryId == categoryId && !_itemDropTargetItemId.HasValue
? "drop-target-item-end"
: string.Empty;
}
protected string GetCategoryDropClass(Guid categoryId)
{
if (_categoryDropTargetId != categoryId)
{
return string.Empty;
}
return _categoryDropAfterTarget ? "drop-target-category-after" : "drop-target-category-before";
}
protected string GetCategoryListEndDropClass()
{
return _categoryDropAtEnd ? "drop-target-category-list-end" : string.Empty;
}
protected async Task OnItemDropAsync(Guid categoryId, Guid targetItemId)
{
if (!_draggedItemId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var draggedItem = Items.FirstOrDefault(x => x.Id == _draggedItemId.Value);
var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (draggedItem is null || targetCategory is null)
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
return;
}
var targetItems = targetCategory.Items.Where(x => x.Id.HasValue).ToList();
var targetIndex = targetItems.FindIndex(x => x.Id == targetItemId);
if (targetIndex < 0)
{
targetIndex = targetItems.Count;
}
var placeAfter = _itemDropTargetItemId == targetItemId && _itemDropAfterTarget;
if (placeAfter)
{
targetIndex++;
}
if (draggedItem.CategoryId == categoryId)
{
var currentIndex = targetItems.FindIndex(x => x.Id == _draggedItemId.Value);
if (currentIndex >= 0 && currentIndex < targetIndex)
{
targetIndex--;
}
}
await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryItemsDropAsync(Guid categoryId)
{
if (!_draggedItemId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (targetCategory is null)
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
return;
}
var targetIndex = targetCategory.Items.Count;
await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryDropAsync(Guid targetCategoryId)
{
if (!_draggedCategoryId.HasValue || _draggedCategoryId == targetCategoryId || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var targetIndex = ItemCategories.ToList().FindIndex(x => x.Id == targetCategoryId);
if (targetIndex < 0)
{
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
return;
}
var placeAfter = _categoryDropTargetId == targetCategoryId && _categoryDropAfterTarget;
if (placeAfter)
{
targetIndex++;
}
var sourceIndex = ItemCategories.ToList().FindIndex(x => x.Id == _draggedCategoryId.Value);
if (sourceIndex >= 0 && sourceIndex < targetIndex)
{
targetIndex--;
}
await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryListEndDropAsync()
{
if (!_draggedCategoryId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, ItemCategories.Count, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task FetchMetadataAsync()
{
if (string.IsNullOrWhiteSpace(ItemModel.ProductUrl))
@@ -268,11 +683,55 @@ public partial class RegistryAdmin : ComponentBase
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
AccessibleUserAddresses = await RegistryService.GetRegistryAccessibleUserAddressesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel = new RegistryItemEditModel
{
CurrencyCode = SettingsModel.CurrencyCode,
DesiredQuantity = 1
DesiredQuantity = 1,
CategoryId = ItemCategories.FirstOrDefault()?.Id
};
}
private async Task ReloadItemManagementAsync()
{
var categories = await RegistryService.GetRegistryItemCategoriesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
var items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = items;
var itemsByCategory = items
.Where(x => x.CategoryId.HasValue)
.GroupBy(x => x.CategoryId!.Value)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RegistryItemEditModel>)g.ToList());
ItemCategories = categories
.OrderBy(x => x.SortOrder)
.Select(category => new RegistryItemCategoryEditModel
{
Id = category.Id,
Name = category.Name,
SortOrder = category.SortOrder,
Items = itemsByCategory.TryGetValue(category.Id, out var categoryItems)
? categoryItems
: []
})
.ToList();
}
private async Task<bool> IsPointerInBottomHalfAsync(string elementId, double clientY)
{
try
{
return await JSRuntime.InvokeAsync<bool>("birthListDrag.isPointerInBottomHalf", elementId, clientY).ConfigureAwait(false);
}
catch (JSException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
}
@@ -158,3 +158,61 @@ h3 {
font-size: 0.8rem;
}
}
/* Category grouping and drag-drop */
.category-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-group {
border: 1px solid #dee2e6;
}
.category-group .card-header {
background-color: #f8f9fa;
}
.category-group [draggable="true"] {
cursor: move;
}
.category-group tbody tr[draggable="true"]:active {
opacity: 0.6;
}
/* Drop indicators */
.category-group.drop-target-category {
border-color: #0d6efd;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.2);
}
.drop-target-category-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.drop-target-category-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.drop-target-item-end {
border-bottom: 3px solid #0d6efd;
}
.table tbody tr.drop-target-item-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.table tbody tr.drop-target-item-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.category-list-end-drop-zone {
min-height: 18px;
border-radius: 0.375rem;
}
.category-list-end-drop-zone.drop-target-category-list-end {
box-shadow: inset 0 -3px 0 #0d6efd;
}
@@ -40,148 +40,165 @@ else
</div>
}
<div class="item-grid">
@foreach (var item in Registry.Items)
<div class="category-list">
@foreach (var category in Registry.Categories)
{
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
var isCollapsed = IsCategoryCollapsed(category.Id);
<section class="category-section">
<button type="button" class="btn btn-outline-secondary category-toggle" @onclick="() => ToggleCategoryCollapse(category.Id)">
<span class="bi @(isCollapsed ? "bi-chevron-right" : "bi-chevron-down")"></span>
<span>@category.Name</span>
<span class="badge bg-secondary">@category.Items.Count</span>
</button>
@if (!isCollapsed)
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
<div class="item-grid mt-3">
@foreach (var item in category.Items)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2"><strong>Participation:</strong> €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
{
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1)
{
<span class="contributor-badge">and others</span>
}
</div>
}
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
</div>
</article>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2"><strong>Participation:</strong> €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
{
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1)
{
<span class="contributor-badge">and others</span>
}
</div>
}
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
</div>
</article>
}
</section>
}
</div>
</div>
@@ -32,12 +32,20 @@ public partial class RegistryPublic : ComponentBase
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
private readonly HashSet<Guid> _collapsedCategoryIds = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null)
{
var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet();
_collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id));
}
if (Registry is not null && !string.IsNullOrWhiteSpace(userId))
{
await RegistryService.LogUserActionAsync(Registry.Id, userId, UserActionType.RegistryLinkOpened, null, null, CancellationToken.None).ConfigureAwait(false);
@@ -274,6 +282,23 @@ public partial class RegistryPublic : ComponentBase
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected void ToggleCategoryCollapse(Guid categoryId)
{
if (_collapsedCategoryIds.Contains(categoryId))
{
_collapsedCategoryIds.Remove(categoryId);
}
else
{
_collapsedCategoryIds.Add(categoryId);
}
}
protected bool IsCategoryCollapsed(Guid categoryId)
{
return _collapsedCategoryIds.Contains(categoryId);
}
protected static decimal? GetParticipationTotalAmount(RegistryPublicItemViewModel item)
{
ArgumentNullException.ThrowIfNull(item);
@@ -141,3 +141,30 @@
.purchaser-item .btn {
margin-left: 0.5rem;
}
.category-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-section {
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.75rem;
background-color: #fff;
}
.category-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-weight: 600;
text-align: left;
}
.category-toggle span.bi {
margin-right: 0.25rem;
}
@@ -25,6 +25,7 @@ public sealed class RegistryCreateModel
public sealed class RegistryItemEditModel
{
public Guid? Id { get; set; }
public Guid? CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public string? PictureUrl { get; set; }
public string? ProductUrl { get; set; }
@@ -92,12 +93,25 @@ public sealed class RegistryPublicViewModel
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicCategoryViewModel> Categories { get; init; } = [];
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicCategoryViewModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicItemViewModel
{
public Guid Id { get; init; }
public Guid CategoryId { get; init; }
public string CategoryName { get; init; } = string.Empty;
public int CategorySortOrder { get; init; }
public int SortOrder { get; init; }
public string Name { get; init; } = string.Empty;
public string? PictureUrl { get; init; }
public string? ProductUrl { get; init; }
@@ -123,6 +137,14 @@ public sealed class RegistryAdminDisplayModel
public string DisplayName { get; init; } = string.Empty;
}
public sealed class RegistryAccessibleUserAddressViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Address { get; init; }
}
public sealed class UrlMetadataResult
{
public string? NormalizedUrl { get; init; }
@@ -144,3 +166,11 @@ public sealed class RegistryActionLogViewModel
public string? Details { get; init; }
public DateTimeOffset CreatedAtUtc { get; init; }
}
public sealed class RegistryItemCategoryEditModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryItemEditModel> Items { get; init; } = [];
}
@@ -10,6 +10,8 @@ namespace BirthList.Web.Features.Registries;
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
{
private const string DefaultCategoryName = "General";
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
@@ -43,6 +45,14 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
{
RegistryId = registry.Id
});
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registry.Id,
Name = DefaultCategoryName,
SortOrder = 0,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return registry.Id;
@@ -125,6 +135,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var registry = await registryDbContext.Registries
.Include(x => x.Items)
.ThenInclude(x => x.Category)
.Include(x => x.ItemCategories)
.Include(x => x.Visits)
.Include(x => x.Admins)
.FirstOrDefaultAsync(x => x.PublicLinkCode == code.Trim(), cancellationToken)
@@ -164,12 +176,12 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var itemIds = registry.Items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
@@ -204,6 +216,77 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var isAdmin = !string.IsNullOrWhiteSpace(userId) && registry.Admins.Any(x => x.UserId == userId);
var mappedItems = registry.Items
.OrderBy(x => x.Category.SortOrder)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
{
Id = x.Id,
CategoryId = x.CategoryId,
CategoryName = x.Category.Name,
CategorySortOrder = x.Category.SortOrder,
SortOrder = x.SortOrder,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
PurchasedQuantity = x.PurchasedQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList();
var mappedCategories = registry.ItemCategories
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(category => new RegistryPublicCategoryViewModel
{
Id = category.Id,
Name = category.Name,
SortOrder = category.SortOrder,
Items = mappedItems
.Where(item => item.CategoryId == category.Id)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToList()
})
.ToList();
return new RegistryPublicViewModel
{
Id = registry.Id,
@@ -223,54 +306,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
CurrentUserId = userId,
IsAdmin = isAdmin,
Items = registry.Items
.OrderBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
{
Id = x.Id,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
PurchasedQuantity = x.PurchasedQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList()
Categories = mappedCategories,
Items = mappedItems
};
}
@@ -402,12 +439,22 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
{
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.Name)
.Include(x => x.Category)
.OrderBy(x => x.Category.SortOrder)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var item in items.Where(x => x.CategoryId == Guid.Empty))
{
item.CategoryId = defaultCategory.Id;
}
var itemIds = items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
@@ -452,6 +499,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
CategoryId = x.CategoryId,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
@@ -533,6 +581,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
return new RegistryItemEditModel
{
Id = item.Id,
CategoryId = item.CategoryId,
Name = item.Name,
PictureUrl = item.PictureUrl,
ProductUrl = item.ProductUrl,
@@ -573,6 +622,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
throw new ArgumentException("Item name is required.", nameof(model));
}
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
RegistryItem entity;
if (model.Id is { } itemId)
{
@@ -582,15 +633,45 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
}
else
{
var categoryId = model.CategoryId ?? defaultCategory.Id;
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
entity = new RegistryItem
{
Id = Guid.NewGuid(),
RegistryId = registryId,
CategoryId = categoryId,
SortOrder = (nextSortOrder ?? -1) + 1,
CreatedAtUtc = DateTimeOffset.UtcNow
};
registryDbContext.RegistryItems.Add(entity);
}
if (model.CategoryId.HasValue && model.CategoryId.Value != entity.CategoryId)
{
var categoryExists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id == model.CategoryId.Value, cancellationToken)
.ConfigureAwait(false);
if (!categoryExists)
{
throw new InvalidOperationException("Category not found.");
}
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == model.CategoryId.Value && x.Id != entity.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
entity.CategoryId = model.CategoryId.Value;
entity.SortOrder = (nextSortOrder ?? -1) + 1;
}
entity.Name = model.Name.Trim();
entity.PictureUrl = string.IsNullOrWhiteSpace(model.PictureUrl) ? null : model.PictureUrl.Trim();
entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim();
@@ -939,4 +1020,425 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
return userId;
}
public async Task<IReadOnlyList<RegistryAccessibleUserAddressViewModel>> GetRegistryAccessibleUserAddressesAsync(Guid registryId, CancellationToken cancellationToken)
{
var userIds = new HashSet<string>(StringComparer.Ordinal);
var adminIds = await registryDbContext.RegistryAdmins
.Where(x => x.RegistryId == registryId)
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var adminId in adminIds)
{
userIds.Add(adminId);
}
var visitorIds = await registryDbContext.RegistryVisits
.Where(x => x.RegistryId == registryId)
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var visitorId in visitorIds)
{
userIds.Add(visitorId);
}
var itemIds = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.Select(x => x.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (itemIds.Count > 0)
{
var purchaserIds = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.Select(x => x.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchaserId in purchaserIds)
{
userIds.Add(purchaserId);
}
var contributorIds = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.Select(x => x.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var contributorId in contributorIds)
{
userIds.Add(contributorId);
}
}
if (userIds.Count == 0)
{
return [];
}
var users = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new
{
x.Id,
x.Email,
x.FirstName,
x.LastName,
x.Address
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return users
.Select(x => new RegistryAccessibleUserAddressViewModel
{
UserId = x.Id,
DisplayName = BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id),
Email = x.Email,
Address = string.IsNullOrWhiteSpace(x.Address) ? null : x.Address.Trim()
})
.OrderBy(x => x.DisplayName)
.ToList();
}
public async Task<IReadOnlyList<RegistryItemCategoryEditModel>> GetRegistryItemCategoriesAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(x => new RegistryItemCategoryEditModel
{
Id = x.Id,
Name = x.Name,
SortOrder = x.SortOrder
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (categories.Count > 0)
{
return categories;
}
var createdCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
return
[
new RegistryItemCategoryEditModel
{
Id = createdCategory.Id,
Name = createdCategory.Name,
SortOrder = createdCategory.SortOrder,
Items = []
}
];
}
public async Task AddRegistryItemCategoryAsync(Guid registryId, string categoryName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(categoryName))
{
throw new ArgumentException("Category name is required.", nameof(categoryName));
}
var normalizedName = categoryName.Trim();
var exists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Name == normalizedName, cancellationToken)
.ConfigureAwait(false);
if (exists)
{
throw new InvalidOperationException("A category with this name already exists.");
}
var sortOrder = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registryId,
Name = normalizedName,
SortOrder = (sortOrder ?? -1) + 1,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task RenameRegistryItemCategoryAsync(Guid registryId, Guid categoryId, string categoryName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(categoryName))
{
throw new ArgumentException("Category name is required.", nameof(categoryName));
}
var normalizedName = categoryName.Trim();
var category = await registryDbContext.RegistryItemCategories
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Category not found.");
var exists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id != categoryId && x.Name == normalizedName, cancellationToken)
.ConfigureAwait(false);
if (exists)
{
throw new InvalidOperationException("A category with this name already exists.");
}
category.Name = normalizedName;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task RemoveRegistryItemCategoryAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
{
var categoryCount = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.CountAsync(cancellationToken)
.ConfigureAwait(false);
if (categoryCount <= 1)
{
throw new InvalidOperationException("At least one category is required.");
}
var category = await registryDbContext.RegistryItemCategories
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false);
if (category is null)
{
return;
}
var fallbackCategory = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId && x.Id != categoryId)
.OrderBy(x => x.SortOrder)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (fallbackCategory is null)
{
throw new InvalidOperationException("At least one category is required.");
}
var categoryItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var destinationSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == fallbackCategory.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
var nextSort = (destinationSortOrder ?? -1) + 1;
foreach (var item in categoryItems)
{
item.CategoryId = fallbackCategory.Id;
item.SortOrder = nextSort;
nextSort++;
}
registryDbContext.RegistryItemCategories.Remove(category);
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
await NormalizeItemOrderAsync(registryId, fallbackCategory.Id, cancellationToken).ConfigureAwait(false);
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task MoveRegistryItemAsync(Guid registryId, Guid itemId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
{
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var categoryExists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false);
if (!categoryExists)
{
throw new InvalidOperationException("Category not found.");
}
var sourceCategoryId = item.CategoryId;
var targetItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId && x.Id != itemId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (targetIndex < 0)
{
targetIndex = 0;
}
if (targetIndex > targetItems.Count)
{
targetIndex = targetItems.Count;
}
targetItems.Insert(targetIndex, item);
for (var i = 0; i < targetItems.Count; i++)
{
targetItems[i].CategoryId = categoryId;
targetItems[i].SortOrder = i;
}
if (sourceCategoryId != categoryId)
{
await NormalizeItemOrderAsync(registryId, sourceCategoryId, cancellationToken).ConfigureAwait(false);
}
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task MoveRegistryCategoryAsync(Guid registryId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var category = categories.FirstOrDefault(x => x.Id == categoryId);
if (category is null)
{
return;
}
categories.Remove(category);
if (targetIndex < 0)
{
targetIndex = 0;
}
if (targetIndex > categories.Count)
{
targetIndex = categories.Count;
}
categories.Insert(targetIndex, category);
for (var i = 0; i < categories.Count; i++)
{
categories[i].SortOrder = i;
}
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<RegistryItemCategory> EnsureDefaultCategoryAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
RegistryItemCategory defaultCategory;
if (categories.Count == 0)
{
defaultCategory = new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registryId,
Name = DefaultCategoryName,
SortOrder = 0,
CreatedAtUtc = DateTimeOffset.UtcNow
};
registryDbContext.RegistryItemCategories.Add(defaultCategory);
}
else
{
defaultCategory = categories.FirstOrDefault(x => x.Name == DefaultCategoryName) ?? categories[0];
}
var unassignedItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == Guid.Empty)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (unassignedItems.Count > 0)
{
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == defaultCategory.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
var next = (nextSortOrder ?? -1) + 1;
foreach (var item in unassignedItems)
{
item.CategoryId = defaultCategory.Id;
item.SortOrder = next;
next++;
}
}
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
await NormalizeItemOrderAsync(registryId, defaultCategory.Id, cancellationToken).ConfigureAwait(false);
if (registryDbContext.ChangeTracker.HasChanges())
{
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return defaultCategory;
}
private async Task NormalizeCategoryOrderAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
for (var i = 0; i < categories.Count; i++)
{
categories[i].SortOrder = i;
}
}
private async Task NormalizeItemOrderAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
{
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
for (var i = 0; i < items.Count; i++)
{
items[i].SortOrder = i;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
window.birthListDrag = window.birthListDrag || {
isPointerInBottomHalf: function (elementId, clientY) {
if (!elementId) {
return false;
}
const element = document.getElementById(elementId);
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
if (!rect || rect.height <= 0) {
return false;
}
const relativeY = clientY - rect.top;
const ratio = relativeY / rect.height;
return ratio >= 0.5;
}
};