Compare commits

..

2 Commits

Author SHA1 Message Date
Arne Moerman 89c439f80b Enhance purchase and contribution management
Build and Push Docker Image / build-and-push (push) Successful in 41s
Refactored UI to consolidate purchase and contribution actions into dynamic buttons. Added modals for managing purchases and contributions with admin-specific functionality. Introduced new properties and methods in `RegistryPublic.razor.cs` for state management and user filtering.

Updated `RegistryService` with methods for handling contributions and purchases, including admin-specific logic, validation, and logging. Improved database operations for robust handling of item totals and user actions. Added utility methods for generating unique codes and managing admin invites.

Refactored user filtering and display logic for admin actions, ensuring dynamic population of selectable users. Optimized handling of user actions and logs for consistency.
2026-05-19 18:18:02 +02:00
Arne Moerman 09349cb7b7 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.
2026-05-19 17:02:31 +02:00
21 changed files with 2941 additions and 307 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,160 @@ 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>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchaseManagementPromptAsync(item.Id)">
@(Registry.IsAdmin ? "Manage purchases" : (item.CurrentUserPurchasedQuantity > 0 ? "Edit purchase" : "Mark purchased"))
</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)">
@(Registry.IsAdmin ? "Manage participations" : (item.CurrentUserContributionAmount > 0 ? "Edit participation" : "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>
@@ -336,3 +348,68 @@ else
</div>
</div>
}
@if (ShowPurchaseManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage purchase</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SavePurchaseManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@if (ShowContributionManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage participation</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Amount</label>
<InputNumber class="form-control" @bind-Value="ManagedContributionAmount" />
<label class="form-label mt-2">Message</label>
<InputText class="form-control" @bind-Value="ManagedContributionMessage" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SaveContributionManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@@ -32,12 +32,30 @@ public partial class RegistryPublic : ComponentBase
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
protected bool ShowPurchaseManagementPrompt { get; private set; }
protected bool ShowContributionManagementPrompt { get; private set; }
protected int ManagedPurchaseQuantity { get; set; } = 1;
protected decimal ManagedContributionAmount { get; set; }
protected string ManagedContributionMessage { get; set; } = string.Empty;
protected string? SelectedPurchaseUserId { get; set; }
protected string? SelectedContributionUserId { get; set; }
protected string UserFilterText { get; set; } = string.Empty;
protected IReadOnlyList<RegistrySelectableUserViewModel> SelectableUsers { 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);
@@ -266,7 +284,7 @@ public partial class RegistryPublic : ComponentBase
return;
}
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None). ConfigureAwait(false);
ShowPartialFulfillWizard = false;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
@@ -274,6 +292,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);
@@ -285,4 +320,291 @@ public partial class RegistryPublic : ComponentBase
ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink);
return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}";
}
protected IEnumerable<RegistrySelectableUserViewModel> FilteredSelectableUsers =>
string.IsNullOrWhiteSpace(UserFilterText)
? SelectableUsers
: SelectableUsers.Where(x => x.DisplayName.Contains(UserFilterText, StringComparison.OrdinalIgnoreCase));
protected async Task OpenPurchaseManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowPurchaseManagementPrompt = true;
ManagedPurchaseQuantity = item.CurrentUserPurchasedQuantity > 0 ? item.CurrentUserPurchasedQuantity : 1;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedPurchaseUserId = item.Purchasers.FirstOrDefault()?.UserId ?? userId;
if (!SelectableUsers.Any(x => x.UserId == SelectedPurchaseUserId) && !string.IsNullOrWhiteSpace(SelectedPurchaseUserId))
{
SelectableUsers =
[
.. SelectableUsers,
new RegistrySelectableUserViewModel { UserId = SelectedPurchaseUserId, DisplayName = SelectedPurchaseUserId }
];
}
}
else
{
SelectedPurchaseUserId = userId;
}
}
protected void ClosePurchaseManagementPrompt()
{
ShowPurchaseManagementPrompt = false;
SelectedPurchaseUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ActiveItemId = Guid.Empty;
}
protected async Task SavePurchaseManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingPurchase = item.Purchasers.FirstOrDefault(x => x.UserId == targetUserId);
if (existingPurchase is null)
{
await RegistryService.AddPurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdatePurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task RemoveManagedPurchaseAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
if (Registry?.IsAdmin == true)
{
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UnmarkPurchaseAsync(ActiveItemId, targetUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task OpenContributionManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowContributionManagementPrompt = true;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedContributionUserId = item.Contributors.FirstOrDefault()?.UserId ?? userId;
}
else
{
SelectedContributionUserId = userId;
}
var currentContribution = item.Contributors.FirstOrDefault(x => x.UserId == SelectedContributionUserId);
ManagedContributionAmount = currentContribution?.Amount ?? item.CurrentUserContributionAmount;
ManagedContributionMessage = $"Participation for item: {item.Name}";
}
protected void CloseContributionManagementPrompt()
{
ShowContributionManagementPrompt = false;
SelectedContributionUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ManagedContributionAmount = 0;
ManagedContributionMessage = string.Empty;
ActiveItemId = Guid.Empty;
}
protected async Task SaveContributionManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty || ManagedContributionAmount <= 0)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingContribution = item.Contributors.FirstOrDefault(x => x.UserId == targetUserId);
if (existingContribution is null)
{
await RegistryService.AddContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdateContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected async Task RemoveManagedContributionAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
await RegistryService.RemoveContributionAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected void SelectManagedPurchaseUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedPurchaseUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Purchasers.FirstOrDefault(x => x.UserId == userId);
ManagedPurchaseQuantity = existing?.Quantity ?? 1;
}
protected void SelectManagedContributionUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedContributionUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Contributors.FirstOrDefault(x => x.UserId == userId);
ManagedContributionAmount = existing?.Amount ?? 0;
}
protected void OnPurchaseManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedPurchaseUser(userId);
}
protected void OnContributionManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedContributionUser(userId);
}
private async Task ReloadRegistryAsync(string 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));
}
}
protected Task OpenContributionActionAsync(Guid itemId, decimal currentUserContributionAmount)
{
if (Registry?.IsAdmin == true || currentUserContributionAmount > 0)
{
return OpenContributionManagementPromptAsync(itemId);
}
OpenContributionPrompt(itemId);
return Task.CompletedTask;
}
}
@@ -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; }
@@ -114,6 +128,7 @@ public sealed class RegistryPublicItemViewModel
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
public int CurrentUserPurchasedQuantity { get; init; }
public decimal CurrentUserContributionAmount { get; init; }
public bool CanViewPurchasers { get; init; }
}
@@ -123,6 +138,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 +167,17 @@ 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; } = [];
}
public sealed class RegistrySelectableUserViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
}
File diff suppressed because it is too large Load Diff
+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;
}
};