Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c439f80b | |||
| 09349cb7b7 |
@@ -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>
|
||||
|
||||
+552
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user