diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/StoreSectionEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/StoreSectionEndpointsTests.cs new file mode 100644 index 0000000..a323851 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/StoreSectionEndpointsTests.cs @@ -0,0 +1,245 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using YesChef.Api.Entities; +using YesChef.Api.Features.Stores; +using YesChef.Api.IntegrationTests.Infrastructure; + +namespace YesChef.Api.IntegrationTests.Features; + +public class StoreSectionEndpointsTests : AuthenticatedIntegrationTest +{ + public Store Store { get; private set; } = null!; + + [Before(Test)] + public async Task SetUpStore() + { + Store = await Data.CreateStoreAsync(); + } + + [Test] + public async Task Create_store_seeds_default_sections() + { + var response = await Client.PostAsJsonAsync("/api/stores", + new StoreEndpoints.CreateStoreRequest("Whole Foods")); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var newStore = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Name == "Whole Foods")); + var seeded = await UseDbAsync(db => + db.StoreSections.Where(s => s.StoreId == newStore.Id).OrderBy(s => s.SortOrder).Select(s => s.Name).ToListAsync()); + + await Assert.That(seeded).IsEquivalentTo(StoreSectionDefaults.Names); + } + + [Test] + public async Task List_returns_sections_in_sort_order() + { + await UseDbAsync(async db => + { + db.StoreSections.AddRange( + new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bravo", SortOrder = 2 }, + new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Alpha", SortOrder = 1 }, + new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Charlie", SortOrder = 3 }); + await db.SaveChangesAsync(); + }); + + var sections = await Client.GetFromJsonAsync>($"/api/stores/{Store.Id}/sections"); + + var names = sections!.Select(s => s.GetProperty("name").GetString()!).ToArray(); + await Assert.That(names).IsEquivalentTo(new[] { "Alpha", "Bravo", "Charlie" }); + } + + [Test] + public async Task List_returns_404_for_unknown_store() + { + var response = await Client.GetAsync("/api/stores/99999/sections"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Create_persists_section() + { + var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections", + new StoreSectionEndpoints.CreateSectionRequest("Produce", 0)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var section = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.StoreId == Store.Id)); + await Assert.That(section.Name).IsEqualTo("Produce"); + } + + [Test] + public async Task Create_returns_409_for_duplicate_name() + { + await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections", + new StoreSectionEndpoints.CreateSectionRequest("Produce")); + + var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections", + new StoreSectionEndpoints.CreateSectionRequest("Produce")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Update_changes_name_and_sort_order() + { + var created = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections", + new StoreSectionEndpoints.CreateSectionRequest("Produce")); + var section = await created.Content.ReadFromJsonAsync(); + var sectionId = section.GetProperty("id").GetInt32(); + + var response = await Client.PutAsJsonAsync($"/api/stores/{Store.Id}/sections/{sectionId}", + new StoreSectionEndpoints.UpdateSectionRequest("Fresh", 5)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var refreshed = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.Id == sectionId)); + await Assert.That(refreshed.Name).IsEqualTo("Fresh"); + await Assert.That(refreshed.SortOrder).IsEqualTo(5); + } + + [Test] + public async Task Delete_removes_section_and_keeps_items_with_null_section() + { + var sectionId = await UseDbAsync(async db => + { + var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return section.Id; + }); + + var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas")); + await UseDbAsync(async db => + { + var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id); + item.SectionId = sectionId; + await db.SaveChangesAsync(); + }); + + var response = await Client.DeleteAsync($"/api/stores/{Store.Id}/sections/{sectionId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id)); + await Assert.That(item.SectionId).IsNull(); + } + + [Test] + public async Task Sections_are_isolated_per_family() + { + await UseDbAsync(async db => + { + db.StoreSections.Add(new StoreSection + { + FamilyId = Store.FamilyId, + StoreId = Store.Id, + Name = "Mine", + }); + await db.SaveChangesAsync(); + }); + + var otherFamily = await UseDbAsync(async db => + { + var family = new Family { Name = "Other", InviteCode = $"other-{Guid.NewGuid():N}" }; + db.Families.Add(family); + await db.SaveChangesAsync(); + return family; + }); + var otherStore = await UseDbAsync(async db => + { + var store = new Store { FamilyId = otherFamily.Id, Name = "Other Store" }; + db.Stores.Add(store); + await db.SaveChangesAsync(); + return store; + }); + + // The default user can't see another family's store. + var response = await Client.GetAsync($"/api/stores/{otherStore.Id}/sections"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Add_item_with_section_persists_section_id() + { + var sectionId = await UseDbAsync(async db => + { + var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return section.Id; + }); + var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User)); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, sectionId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id)); + await Assert.That(item.SectionId).IsEqualTo(sectionId); + } + + [Test] + public async Task Add_item_rejects_section_from_other_store() + { + var otherStore = await Data.CreateStoreAsync(b => b.Named("Other")); + var alienSectionId = await UseDbAsync(async db => + { + var section = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Alien" }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return section.Id; + }); + var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User)); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, alienSectionId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Set_item_section_updates_existing_item() + { + var sectionId = await UseDbAsync(async db => + { + var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return section.Id; + }); + var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas")); + var itemId = list.Items[0].Id; + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(sectionId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId)); + await Assert.That(item.SectionId).IsEqualTo(sectionId); + } + + [Test] + public async Task Set_item_section_clears_when_null() + { + var sectionId = await UseDbAsync(async db => + { + var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return section.Id; + }); + var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas")); + var itemId = list.Items[0].Id; + await UseDbAsync(async db => + { + var item = await db.ShoppingListItems.SingleAsync(i => i.Id == itemId); + item.SectionId = sectionId; + await db.SaveChangesAsync(); + }); + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId)); + await Assert.That(item.SectionId).IsNull(); + } +} diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 4ae792b..2bffe08 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -9,6 +9,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) public DbSet FamilyMemberships => Set(); public DbSet Users => Set(); public DbSet Stores => Set(); + public DbSet StoreSections => Set(); public DbSet ShoppingLists => Set(); public DbSet ShoppingListItems => Set(); public DbSet Recipes => Set(); @@ -44,6 +45,14 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(s => s.Name).HasMaxLength(100); }); + modelBuilder.Entity(e => + { + e.HasOne(s => s.Family).WithMany().HasForeignKey(s => s.FamilyId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(s => s.Store).WithMany().HasForeignKey(s => s.StoreId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(s => new { s.StoreId, s.Name }).IsUnique(); + e.Property(s => s.Name).HasMaxLength(100); + }); + modelBuilder.Entity(e => { e.Property(l => l.Name).HasMaxLength(200); @@ -61,6 +70,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull); e.HasIndex(i => i.FamilyId); }); diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs index f53806d..17ecfb9 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -14,6 +14,8 @@ public class ShoppingListItem public int SortOrder { get; set; } public int? RecipeId { get; set; } public Recipe? Recipe { get; set; } + public int? SectionId { get; set; } + public StoreSection? Section { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? RemovedAt { get; set; } public int? RemovedByUserId { get; set; } diff --git a/src/backend/YesChef.Api/Entities/StoreSection.cs b/src/backend/YesChef.Api/Entities/StoreSection.cs new file mode 100644 index 0000000..0d30e58 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/StoreSection.cs @@ -0,0 +1,12 @@ +namespace YesChef.Api.Entities; + +public class StoreSection +{ + public int Id { get; set; } + public int FamilyId { get; set; } + public Family Family { get; set; } = null!; + public int StoreId { get; set; } + public Store Store { get; set; } = null!; + public required string Name { get; set; } + public int SortOrder { get; set; } +} diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index 8007af5..f848589 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -10,7 +10,8 @@ public static class ShoppingListEndpoints { public record CreateListRequest(string Name, int StoreId); public record UpdateListRequest(string Name, int StoreId); - public record AddItemRequest(string Name, int SortOrder = 0); + public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null); + public record SetItemSectionRequest(int? SectionId); private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; @@ -104,6 +105,12 @@ public static class ShoppingListEndpoints if (list is null) return Results.NotFound(); + var sections = await db.StoreSections + .Where(s => s.StoreId == list.StoreId && s.FamilyId == familyId) + .OrderBy(s => s.SortOrder).ThenBy(s => s.Name) + .Select(s => new { s.Id, s.Name, s.SortOrder }) + .ToListAsync(); + return Results.Ok(new { list.Id, @@ -111,6 +118,7 @@ public static class ShoppingListEndpoints Store = new { list.Store.Id, list.Store.Name }, list.IsArchived, list.UpdatedAt, + Sections = sections, Items = list.Items.Select(i => new { i.Id, @@ -118,6 +126,7 @@ public static class ShoppingListEndpoints i.IsChecked, CheckedByUserName = i.CheckedByUser?.Name, i.SortOrder, + i.SectionId, RecipeTitle = i.Recipe?.Title }) }); @@ -164,20 +173,45 @@ public static class ShoppingListEndpoints var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == listId && l.FamilyId == familyId); if (list is null) return Results.NotFound(); + // Reject section IDs that don't belong to the list's store/family. + if (request.SectionId is int sectionId && + !await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == list.StoreId && s.FamilyId == familyId)) + return Results.BadRequest(new { error = "Unknown section." }); + var item = new ShoppingListItem { FamilyId = familyId, ShoppingListId = listId, Name = request.Name, - SortOrder = request.SortOrder + SortOrder = request.SortOrder, + SectionId = request.SectionId, }; db.ShoppingListItems.Add(item); list.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId }); await BroadcastListSummary(hub, db, listId, familyId); - return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder }); + return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId }); + }); + + group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext hub) => + { + var familyId = http.User.GetFamilyId(); + var item = await db.ShoppingListItems + .Include(i => i.ShoppingList) + .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null); + if (item is null) return Results.NotFound(); + + if (request.SectionId is int sectionId && + !await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId)) + return Results.BadRequest(new { error = "Unknown section." }); + + item.SectionId = request.SectionId; + await db.SaveChangesAsync(); + + await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId }); + return Results.Ok(); }); group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext hub, HttpContext http) => @@ -237,6 +271,7 @@ public static class ShoppingListEndpoints item.IsChecked, CheckedByUserName = item.CheckedByUser?.Name, item.SortOrder, + item.SectionId, RecipeTitle = item.Recipe?.Title }); await BroadcastListSummary(hub, db, listId, familyId); @@ -272,7 +307,7 @@ public static class ShoppingListEndpoints foreach (var item in newItems) { - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, RecipeTitle = recipe.Title }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, RecipeTitle = recipe.Title }); } await BroadcastListSummary(hub, db, listId, familyId); diff --git a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs index c3fba3c..83588e9 100644 --- a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs @@ -35,6 +35,17 @@ public static class StoreEndpoints }; db.Stores.Add(store); await db.SaveChangesAsync(); + + // Seed default sections so a brand-new store has a usable walk order out of the box. + db.StoreSections.AddRange(StoreSectionDefaults.Names.Select((name, idx) => new StoreSection + { + FamilyId = familyId, + StoreId = store.Id, + Name = name, + SortOrder = idx, + })); + await db.SaveChangesAsync(); + return Results.Created($"/api/stores/{store.Id}", store); }); diff --git a/src/backend/YesChef.Api/Features/Stores/StoreSectionDefaults.cs b/src/backend/YesChef.Api/Features/Stores/StoreSectionDefaults.cs new file mode 100644 index 0000000..6359367 --- /dev/null +++ b/src/backend/YesChef.Api/Features/Stores/StoreSectionDefaults.cs @@ -0,0 +1,17 @@ +namespace YesChef.Api.Features.Stores; + +public static class StoreSectionDefaults +{ + public static readonly string[] Names = + [ + "Produce", + "Meat & Seafood", + "Dairy", + "Bakery", + "Frozen", + "Pantry", + "Condiments", + "Beverages", + "Other", + ]; +} diff --git a/src/backend/YesChef.Api/Features/Stores/StoreSectionEndpoints.cs b/src/backend/YesChef.Api/Features/Stores/StoreSectionEndpoints.cs new file mode 100644 index 0000000..ed35857 --- /dev/null +++ b/src/backend/YesChef.Api/Features/Stores/StoreSectionEndpoints.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Stores; + +public static class StoreSectionEndpoints +{ + public record CreateSectionRequest(string Name, int SortOrder = 0); + public record UpdateSectionRequest(string Name, int SortOrder); + + public static RouteGroupBuilder MapStoreSectionEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", async (int storeId, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + if (!await db.Stores.AnyAsync(s => s.Id == storeId && s.FamilyId == familyId)) + return Results.NotFound(); + + var sections = await db.StoreSections + .Where(s => s.StoreId == storeId && s.FamilyId == familyId) + .OrderBy(s => s.SortOrder).ThenBy(s => s.Name) + .Select(s => new { s.Id, s.Name, s.SortOrder }) + .ToListAsync(); + return Results.Ok(sections); + }); + + group.MapPost("/", async (int storeId, CreateSectionRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + if (!await db.Stores.AnyAsync(s => s.Id == storeId && s.FamilyId == familyId)) + return Results.NotFound(); + + if (await db.StoreSections.AnyAsync(s => s.StoreId == storeId && s.Name == request.Name)) + return Results.Conflict(new { error = $"A section named \"{request.Name}\" already exists." }); + + var section = new StoreSection + { + FamilyId = familyId, + StoreId = storeId, + Name = request.Name, + SortOrder = request.SortOrder, + }; + db.StoreSections.Add(section); + await db.SaveChangesAsync(); + return Results.Created($"/api/stores/{storeId}/sections/{section.Id}", + new { section.Id, section.Name, section.SortOrder }); + }); + + group.MapPut("/{sectionId:int}", async (int storeId, int sectionId, UpdateSectionRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var section = await db.StoreSections + .FirstOrDefaultAsync(s => s.Id == sectionId && s.StoreId == storeId && s.FamilyId == familyId); + if (section is null) return Results.NotFound(); + + if (request.Name != section.Name && + await db.StoreSections.AnyAsync(s => s.StoreId == storeId && s.Name == request.Name && s.Id != sectionId)) + return Results.Conflict(new { error = $"A section named \"{request.Name}\" already exists." }); + + section.Name = request.Name; + section.SortOrder = request.SortOrder; + await db.SaveChangesAsync(); + return Results.Ok(new { section.Id, section.Name, section.SortOrder }); + }); + + group.MapDelete("/{sectionId:int}", async (int storeId, int sectionId, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var section = await db.StoreSections + .FirstOrDefaultAsync(s => s.Id == sectionId && s.StoreId == storeId && s.FamilyId == familyId); + if (section is null) return Results.NotFound(); + + db.StoreSections.Remove(section); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + return group; + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.Designer.cs b/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.Designer.cs new file mode 100644 index 0000000..11a5de8 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.Designer.cs @@ -0,0 +1,524 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260509025211_AddStoreSections")] + partial class AddStoreSections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.cs b/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.cs new file mode 100644 index 0000000..e6b9a8e --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509025211_AddStoreSections.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddStoreSections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SectionId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "StoreSections", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FamilyId = table.Column(type: "integer", nullable: false), + StoreId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreSections", x => x.Id); + table.ForeignKey( + name: "FK_StoreSections_Families_FamilyId", + column: x => x.FamilyId, + principalTable: "Families", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StoreSections_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_SectionId", + table: "ShoppingListItems", + column: "SectionId"); + + migrationBuilder.CreateIndex( + name: "IX_StoreSections_FamilyId", + table: "StoreSections", + column: "FamilyId"); + + migrationBuilder.CreateIndex( + name: "IX_StoreSections_StoreId_Name", + table: "StoreSections", + columns: new[] { "StoreId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_StoreSections_SectionId", + table: "ShoppingListItems", + column: "SectionId", + principalTable: "StoreSections", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_StoreSections_SectionId", + table: "ShoppingListItems"); + + migrationBuilder.DropTable( + name: "StoreSections"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_SectionId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "SectionId", + table: "ShoppingListItems"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index bbbef58..1b3d993 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -229,6 +229,9 @@ namespace YesChef.Api.Migrations b.Property("RemovedByUserId") .HasColumnType("integer"); + b.Property("SectionId") + .HasColumnType("integer"); + b.Property("ShoppingListId") .HasColumnType("integer"); @@ -245,6 +248,8 @@ namespace YesChef.Api.Migrations b.HasIndex("RemovedByUserId"); + b.HasIndex("SectionId"); + b.HasIndex("ShoppingListId"); b.ToTable("ShoppingListItems"); @@ -280,6 +285,38 @@ namespace YesChef.Api.Migrations b.ToTable("Stores"); }); + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + modelBuilder.Entity("YesChef.Api.Entities.User", b => { b.Property("Id") @@ -415,6 +452,11 @@ namespace YesChef.Api.Migrations .HasForeignKey("RemovedByUserId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") .WithMany("Items") .HasForeignKey("ShoppingListId") @@ -429,6 +471,8 @@ namespace YesChef.Api.Migrations b.Navigation("RemovedByUser"); + b.Navigation("Section"); + b.Navigation("ShoppingList"); }); @@ -443,6 +487,25 @@ namespace YesChef.Api.Migrations b.Navigation("Family"); }); + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => { b.Navigation("Ingredients"); diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 2fbad29..d90ad87 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -123,7 +123,8 @@ app.MapGet("/health", async (YesChefDb db) => app.MapGroup("/api/auth").MapAuthEndpoints(); app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization(); -app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization(); +var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization(); +storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); app.MapHub("/hubs/shopping-list"); diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index 7f2cd51..e571992 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -13,20 +13,32 @@ isChecked: boolean; checkedByUserName: string | null; sortOrder: number; + sectionId: number | null; recipeTitle: string | null; } + interface Section { + id: number; + name: string; + sortOrder: number; + } + interface ShoppingListDetail { id: number; name: string; store: { id: number; name: string }; isArchived: boolean; + sections: Section[]; items: ListItem[]; } + const UNCATEGORIZED_KEY = -1; + let list = $state(null); let items = $state([]); + let sections = $state([]); let newItemName = $state(''); + let newItemSectionId = $state(null); let loading = $state(true); let connection: HubConnection | null = null; @@ -34,16 +46,46 @@ const uncheckedItems = $derived(items.filter((i) => !i.isChecked)); const checkedItems = $derived(items.filter((i) => i.isChecked)); + // Order: sections by SortOrder, with uncategorized bucket appended last. + const orderedSectionKeys = $derived([ + ...[...sections].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((s) => s.id), + UNCATEGORIZED_KEY, + ]); + + const uncheckedGroups = $derived(groupBySection(uncheckedItems)); + const checkedGroups = $derived(groupBySection(checkedItems)); + + function groupBySection(source: ListItem[]) { + const buckets = new Map(); + for (const item of source) { + const key = item.sectionId ?? UNCATEGORIZED_KEY; + const bucket = buckets.get(key); + if (bucket) bucket.push(item); + else buckets.set(key, [item]); + } + return orderedSectionKeys + .map((key) => ({ + key, + name: + key === UNCATEGORIZED_KEY + ? 'Uncategorized' + : sections.find((s) => s.id === key)?.name ?? 'Uncategorized', + items: (buckets.get(key) ?? []).sort((a, b) => a.sortOrder - b.sortOrder), + })) + .filter((g) => g.items.length > 0); + } + onMount(async () => { const data = await api(`/api/lists/${listId}`); list = data; items = data.items; + sections = data.sections; loading = false; connection = await startConnection(); await connection.invoke('JoinList', listId); - connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; recipeTitle?: string }) => { + connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => { if (!items.find((i) => i.id === data.id)) { items = [ ...items, @@ -53,6 +95,7 @@ isChecked: false, checkedByUserName: null, sortOrder: data.sortOrder, + sectionId: data.sectionId ?? null, recipeTitle: data.recipeTitle ?? null } ]; @@ -67,20 +110,17 @@ ); }); + connection.on('ItemSectionChanged', (data: { id: number; sectionId: number | null }) => { + items = items.map((i) => (i.id === data.id ? { ...i, sectionId: data.sectionId } : i)); + }); + connection.on('ItemRemoved', (data: { id: number }) => { items = items.filter((i) => i.id !== data.id); }); connection.on( 'ItemRestored', - (data: { - id: number; - name: string; - isChecked: boolean; - checkedByUserName: string | null; - sortOrder: number; - recipeTitle: string | null; - }) => { + (data: ListItem) => { if (!items.find((i) => i.id === data.id)) { items = [...items, data]; } @@ -106,7 +146,11 @@ const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0; await api('/api/lists/' + listId + '/items', { method: 'POST', - body: JSON.stringify({ name: newItemName, sortOrder: maxSort + 1 }) + body: JSON.stringify({ + name: newItemName, + sortOrder: maxSort + 1, + sectionId: newItemSectionId + }) }); newItemName = ''; } @@ -115,6 +159,19 @@ await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' }); } + async function setItemSection(itemId: number, sectionId: number | null) { + // Optimistic — the SignalR ItemSectionChanged echo will reconcile. + items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i)); + try { + await api(`/api/lists/${listId}/items/${itemId}/section`, { + method: 'PATCH', + body: JSON.stringify({ sectionId }) + }); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to update section'); + } + } + async function removeItem(itemId: number, itemName: string) { await api(`/api/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); toast.info(`Removed "${itemName}"`, { @@ -152,13 +209,25 @@ -
{ e.preventDefault(); addItem(); }} class="mb-4 flex gap-2"> + { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2"> + {#if sections.length > 0} + + {/if}
-
- {item.name} - {#if item.recipeTitle} - from {item.recipeTitle} - {/if} -
- - + {#if uncheckedGroups.length > 0} +
+ {#each uncheckedGroups as group (group.key)} +
+

+ {group.name} +

+
    + {#each group.items as item (item.id)} +
  • + +
    + {item.name} + {#if item.recipeTitle} + from {item.recipeTitle} + {/if} +
    + {#if sections.length > 0} + + {/if} + +
  • + {/each} +
+
{/each} - +
{:else if checkedItems.length === 0}

No items yet — add some above

{/if} @@ -201,32 +292,41 @@

Checked ({checkedItems.length})

-
    - {#each checkedItems as item (item.id)} -
  • - -
    - {item.name} - {#if item.checkedByUserName} - {item.checkedByUserName} - {/if} -
    - -
  • +
    + {#each checkedGroups as group (group.key)} +
    +

    + {group.name} +

    +
      + {#each group.items as item (item.id)} +
    • + +
      + {item.name} + {#if item.checkedByUserName} + {item.checkedByUserName} + {/if} +
      + +
    • + {/each} +
    +
    {/each} -
+ {/if} diff --git a/src/frontend/src/routes/stores/+page.svelte b/src/frontend/src/routes/stores/+page.svelte index 5102c87..9ef17a0 100644 --- a/src/frontend/src/routes/stores/+page.svelte +++ b/src/frontend/src/routes/stores/+page.svelte @@ -9,6 +9,12 @@ sortOrder: number; } + interface Section { + id: number; + name: string; + sortOrder: number; + } + let stores = $state([]); let newName = $state(''); let editingId = $state(null); @@ -16,6 +22,13 @@ let loading = $state(true); let pendingDelete = $state(null); + let expandedStoreId = $state(null); + let sectionsByStore = $state>({}); + let sectionsLoading = $state(null); + let newSectionName = $state(''); + let editingSectionId = $state(null); + let editSectionName = $state(''); + onMount(async () => { stores = await api('/api/stores'); loading = false; @@ -70,6 +83,72 @@ toast.error(e instanceof Error ? e.message : 'Failed to delete store'); } } + + async function toggleSections(storeId: number) { + if (expandedStoreId === storeId) { + expandedStoreId = null; + return; + } + expandedStoreId = storeId; + editingSectionId = null; + newSectionName = ''; + if (!sectionsByStore[storeId]) { + sectionsLoading = storeId; + try { + sectionsByStore[storeId] = await api(`/api/stores/${storeId}/sections`); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to load sections'); + } finally { + sectionsLoading = null; + } + } + } + + async function addSection(storeId: number) { + if (!newSectionName.trim()) return; + const existing = sectionsByStore[storeId] ?? []; + try { + const created = await api
(`/api/stores/${storeId}/sections`, { + method: 'POST', + body: JSON.stringify({ name: newSectionName, sortOrder: existing.length }) + }); + sectionsByStore[storeId] = [...existing, created]; + newSectionName = ''; + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to add section'); + } + } + + function startEditSection(section: Section) { + editingSectionId = section.id; + editSectionName = section.name; + } + + async function saveSectionEdit(storeId: number) { + if (!editSectionName.trim() || !editingSectionId) return; + const section = sectionsByStore[storeId].find((s) => s.id === editingSectionId)!; + try { + const updated = await api
(`/api/stores/${storeId}/sections/${editingSectionId}`, { + method: 'PUT', + body: JSON.stringify({ name: editSectionName, sortOrder: section.sortOrder }) + }); + sectionsByStore[storeId] = sectionsByStore[storeId].map((s) => + s.id === updated.id ? updated : s + ); + editingSectionId = null; + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to update section'); + } + } + + async function deleteSection(storeId: number, sectionId: number) { + try { + await api(`/api/stores/${storeId}/sections/${sectionId}`, { method: 'DELETE' }); + sectionsByStore[storeId] = sectionsByStore[storeId].filter((s) => s.id !== sectionId); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to delete section'); + } + }
@@ -95,21 +174,79 @@ {:else}
    {#each stores as store (store.id)} -
  • - {#if editingId === store.id} -
    { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2"> - - - -
    - {:else} - {store.name} - - +
  • +
    + {#if editingId === store.id} +
    { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2"> + + + +
    + {:else} + + + + {/if} +
    + + {#if expandedStoreId === store.id} +
    +

    Sections

    + + {#if sectionsLoading === store.id} +

    Loading...

    + {:else} + {#if (sectionsByStore[store.id] ?? []).length === 0} +

    No sections — add one below

    + {:else} +
      + {#each sectionsByStore[store.id] as section (section.id)} +
    • + {#if editingSectionId === section.id} +
      { e.preventDefault(); saveSectionEdit(store.id); }} class="flex flex-1 gap-2"> + + + +
      + {:else} + {section.name} + + + {/if} +
    • + {/each} +
    + {/if} + +
    { e.preventDefault(); addSection(store.id); }} class="flex gap-2"> + + +
    + {/if} +
    {/if}
  • {/each}