Add support for item categories in registries

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

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

Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
This commit is contained in:
Arne Moerman
2026-05-19 17:02:31 +02:00
parent fa704ab996
commit 09349cb7b7
21 changed files with 2334 additions and 252 deletions
@@ -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">