Add structured quantities + units to shopping list items
Mirrors the Phase 2 work on RecipeIngredient: ShoppingListItem grows Quantity (decimal), UnitOfMeasureId / FamilyUnitOfMeasureId, IsApproximate, and QuantityNote. The recipe-to-list copy now carries structured fields verbatim instead of folding them into the free-form Name, and the unit-in-use guard now also blocks deleting a family unit that's referenced by a shopping list item. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,35 @@ public sealed class ShoppingListBuilder
|
|||||||
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||||
public ShoppingListBuilder Archived() { _archived = true; return this; }
|
public ShoppingListBuilder Archived() { _archived = true; return this; }
|
||||||
|
|
||||||
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
|
public ShoppingListBuilder WithItem(
|
||||||
|
string name,
|
||||||
|
bool isChecked = false,
|
||||||
|
int sortOrder = 0,
|
||||||
|
decimal? quantity = null,
|
||||||
|
int? unitOfMeasureId = null,
|
||||||
|
int? familyUnitOfMeasureId = null)
|
||||||
{
|
{
|
||||||
_items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder });
|
_items.Add(new ShoppingListItem
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
IsChecked = isChecked,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
Quantity = quantity,
|
||||||
|
UnitOfMeasureId = unitOfMeasureId,
|
||||||
|
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShoppingListBuilder WithApproximateItem(string name, string quantityNote, int sortOrder = 0)
|
||||||
|
{
|
||||||
|
_items.Add(new ShoppingListItem
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
IsApproximate = true,
|
||||||
|
QuantityNote = quantityNote,
|
||||||
|
});
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
|
public async Task Add_recipe_copies_structured_quantity_to_items()
|
||||||
{
|
{
|
||||||
var cupId = await UseDbAsync(async db =>
|
var cupId = await UseDbAsync(async db =>
|
||||||
{
|
{
|
||||||
@@ -471,10 +471,134 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(items.Count).IsEqualTo(4);
|
await Assert.That(items.Count).IsEqualTo(4);
|
||||||
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
||||||
{
|
{
|
||||||
"existing", "2 cup flour", "2 eggs", "salt (to taste)"
|
"existing", "flour", "eggs", "salt"
|
||||||
});
|
});
|
||||||
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
||||||
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
|
|
||||||
|
var flour = items.Single(i => i.Name == "flour");
|
||||||
|
await Assert.That(flour.Quantity).IsEqualTo(2m);
|
||||||
|
await Assert.That(flour.UnitOfMeasureId).IsEqualTo(cupId);
|
||||||
|
await Assert.That(flour.IsApproximate).IsFalse();
|
||||||
|
|
||||||
|
var eggs = items.Single(i => i.Name == "eggs");
|
||||||
|
await Assert.That(eggs.Quantity).IsEqualTo(2m);
|
||||||
|
await Assert.That(eggs.UnitOfMeasureId).IsNull();
|
||||||
|
|
||||||
|
var salt = items.Single(i => i.Name == "salt");
|
||||||
|
await Assert.That(salt.IsApproximate).IsTrue();
|
||||||
|
await Assert.That(salt.QuantityNote).IsEqualTo("to taste");
|
||||||
|
await Assert.That(salt.Quantity).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_persists_structured_quantity()
|
||||||
|
{
|
||||||
|
var cupId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("flour", Quantity: 2.5m, UnitOfMeasureId: cupId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
|
||||||
|
await Assert.That(item.Quantity).IsEqualTo(2.5m);
|
||||||
|
await Assert.That(item.UnitOfMeasureId).IsEqualTo(cupId);
|
||||||
|
await Assert.That(item.IsApproximate).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_persists_approximate_note_and_clears_structured_fields()
|
||||||
|
{
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("salt", Quantity: 1m, IsApproximate: true, QuantityNote: "to taste"));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
|
||||||
|
await Assert.That(item.IsApproximate).IsTrue();
|
||||||
|
await Assert.That(item.QuantityNote).IsEqualTo("to taste");
|
||||||
|
await Assert.That(item.Quantity).IsNull();
|
||||||
|
await Assert.That(item.UnitOfMeasureId).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_rejects_setting_both_unit_ids()
|
||||||
|
{
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("flour", UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_rejects_other_familys_family_unit()
|
||||||
|
{
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
var foreignUnitId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var otherFamily = new Family { Name = "Other-Unit", InviteCode = "other-unit-code" };
|
||||||
|
db.Families.Add(otherFamily);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = otherFamily.Id,
|
||||||
|
SingularName = "scoop", PluralName = "scoops",
|
||||||
|
Abbreviation = "scp", Category = UnitCategory.Count,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("flour", FamilyUnitOfMeasureId: foreignUnitId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Get_list_projects_structured_quantity_fields()
|
||||||
|
{
|
||||||
|
var cupId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup-proj", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup-proj", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u.Id;
|
||||||
|
});
|
||||||
|
var list = await CreateListAsync(b => b
|
||||||
|
.WithItem("flour", quantity: 3m, unitOfMeasureId: cupId)
|
||||||
|
.WithApproximateItem("salt", "to taste", sortOrder: 2));
|
||||||
|
|
||||||
|
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
|
||||||
|
var items = body.GetProperty("items").EnumerateArray().ToArray();
|
||||||
|
|
||||||
|
var flour = items.Single(i => i.GetProperty("name").GetString() == "flour");
|
||||||
|
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(3m);
|
||||||
|
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(cupId);
|
||||||
|
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
|
||||||
|
|
||||||
|
var salt = items.Single(i => i.GetProperty("name").GetString() == "salt");
|
||||||
|
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
||||||
|
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
||||||
|
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -234,6 +234,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(remaining).IsEqualTo(1);
|
await Assert.That(remaining).IsEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_family_unit_returns_409_when_referenced_by_shopping_list_item()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var store = await Data.CreateStoreAsync();
|
||||||
|
var unit = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var u = new FamilyUnitOfMeasure
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
SingularName = "scoop", PluralName = "scoops",
|
||||||
|
Abbreviation = "scp", Category = UnitCategory.Count,
|
||||||
|
};
|
||||||
|
db.FamilyUnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var list = new ShoppingList
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Name = "List with unit",
|
||||||
|
StoreId = store.Id,
|
||||||
|
CreatedByUserId = User.Id,
|
||||||
|
Items = new List<ShoppingListItem>
|
||||||
|
{
|
||||||
|
new() { FamilyId = familyId, Name = "ice cream", Quantity = 2m, FamilyUnitOfMeasureId = u.Id },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
db.ShoppingLists.Add(list);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Endpoints_require_authentication()
|
public async Task Endpoints_require_authentication()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
modelBuilder.Entity<ShoppingListItem>(e =>
|
modelBuilder.Entity<ShoppingListItem>(e =>
|
||||||
{
|
{
|
||||||
e.Property(i => i.Name).HasMaxLength(300);
|
e.Property(i => i.Name).HasMaxLength(300);
|
||||||
|
e.Property(i => i.Quantity).HasPrecision(12, 4);
|
||||||
|
e.Property(i => i.QuantityNote).HasMaxLength(200);
|
||||||
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
|
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.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||||
@@ -102,6 +104,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
// Restrict on delete: the unit-delete endpoint already blocks
|
||||||
|
// deletion when an item references the unit; this is defense in depth.
|
||||||
|
e.HasOne(i => i.UnitOfMeasure).WithMany().HasForeignKey(i => i.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(i => i.FamilyUnitOfMeasure).WithMany().HasForeignKey(i => i.FamilyUnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasIndex(i => i.FamilyId);
|
e.HasIndex(i => i.FamilyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ public class ShoppingListItem
|
|||||||
public Product? Product { get; set; }
|
public Product? Product { get; set; }
|
||||||
public int? FamilyProductId { get; set; }
|
public int? FamilyProductId { get; set; }
|
||||||
public FamilyProduct? FamilyProduct { get; set; }
|
public FamilyProduct? FamilyProduct { get; set; }
|
||||||
|
// Structured quantity. Null = unspecified (free-form Name carries the meaning).
|
||||||
|
public decimal? Quantity { get; set; }
|
||||||
|
// At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row.
|
||||||
|
public int? UnitOfMeasureId { get; set; }
|
||||||
|
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||||
|
public int? FamilyUnitOfMeasureId { get; set; }
|
||||||
|
public FamilyUnitOfMeasure? FamilyUnitOfMeasure { get; set; }
|
||||||
|
// Approximations like "to taste" / "a pinch" — render QuantityNote instead
|
||||||
|
// of the structured Quantity/Unit pair when this flag is set.
|
||||||
|
public bool IsApproximate { get; set; }
|
||||||
|
public string? QuantityNote { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? RemovedAt { get; set; }
|
public DateTime? RemovedAt { get; set; }
|
||||||
public int? RemovedByUserId { get; set; }
|
public int? RemovedByUserId { get; set; }
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ public static class ShoppingListEndpoints
|
|||||||
{
|
{
|
||||||
public record CreateListRequest(string Name, int StoreId);
|
public record CreateListRequest(string Name, int StoreId);
|
||||||
public record UpdateListRequest(string Name, int StoreId);
|
public record UpdateListRequest(string Name, int StoreId);
|
||||||
public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null);
|
public record AddItemRequest(
|
||||||
|
string Name,
|
||||||
|
int SortOrder = 0,
|
||||||
|
int? SectionId = null,
|
||||||
|
int? ProductId = null,
|
||||||
|
int? FamilyProductId = null,
|
||||||
|
decimal? Quantity = null,
|
||||||
|
int? UnitOfMeasureId = null,
|
||||||
|
int? FamilyUnitOfMeasureId = null,
|
||||||
|
bool IsApproximate = false,
|
||||||
|
string? QuantityNote = null);
|
||||||
public record SetItemSectionRequest(int? SectionId);
|
public record SetItemSectionRequest(int? SectionId);
|
||||||
|
|
||||||
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
||||||
@@ -129,7 +139,12 @@ public static class ShoppingListEndpoints
|
|||||||
i.SectionId,
|
i.SectionId,
|
||||||
RecipeTitle = i.Recipe?.Title,
|
RecipeTitle = i.Recipe?.Title,
|
||||||
i.ProductId,
|
i.ProductId,
|
||||||
i.FamilyProductId
|
i.FamilyProductId,
|
||||||
|
i.Quantity,
|
||||||
|
i.UnitOfMeasureId,
|
||||||
|
i.FamilyUnitOfMeasureId,
|
||||||
|
i.IsApproximate,
|
||||||
|
i.QuantityNote
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -183,6 +198,9 @@ public static class ShoppingListEndpoints
|
|||||||
if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError)
|
if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError)
|
||||||
return productError;
|
return productError;
|
||||||
|
|
||||||
|
if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError)
|
||||||
|
return unitError;
|
||||||
|
|
||||||
// Auto-assign a section from memory when caller didn't pick one
|
// Auto-assign a section from memory when caller didn't pick one
|
||||||
// but supplied a product link — "we put bananas in Produce last
|
// but supplied a product link — "we put bananas in Produce last
|
||||||
// time we shopped here, do it again."
|
// time we shopped here, do it again."
|
||||||
@@ -198,6 +216,11 @@ public static class ShoppingListEndpoints
|
|||||||
SectionId = resolvedSectionId,
|
SectionId = resolvedSectionId,
|
||||||
ProductId = request.ProductId,
|
ProductId = request.ProductId,
|
||||||
FamilyProductId = request.FamilyProductId,
|
FamilyProductId = request.FamilyProductId,
|
||||||
|
Quantity = request.IsApproximate ? null : request.Quantity,
|
||||||
|
UnitOfMeasureId = request.IsApproximate ? null : request.UnitOfMeasureId,
|
||||||
|
FamilyUnitOfMeasureId = request.IsApproximate ? null : request.FamilyUnitOfMeasureId,
|
||||||
|
IsApproximate = request.IsApproximate,
|
||||||
|
QuantityNote = request.IsApproximate ? request.QuantityNote : null,
|
||||||
};
|
};
|
||||||
db.ShoppingListItems.Add(item);
|
db.ShoppingListItems.Add(item);
|
||||||
list.UpdatedAt = DateTime.UtcNow;
|
list.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -210,9 +233,9 @@ public static class ShoppingListEndpoints
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
|
return Results.Created($"/api/lists/{listId}/items/{item.Id}", ItemAddedPayload(item, recipeTitle: null));
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
|
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
|
||||||
@@ -297,7 +320,12 @@ public static class ShoppingListEndpoints
|
|||||||
item.SectionId,
|
item.SectionId,
|
||||||
RecipeTitle = item.Recipe?.Title,
|
RecipeTitle = item.Recipe?.Title,
|
||||||
item.ProductId,
|
item.ProductId,
|
||||||
item.FamilyProductId
|
item.FamilyProductId,
|
||||||
|
item.Quantity,
|
||||||
|
item.UnitOfMeasureId,
|
||||||
|
item.FamilyUnitOfMeasureId,
|
||||||
|
item.IsApproximate,
|
||||||
|
item.QuantityNote
|
||||||
});
|
});
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
@@ -330,16 +358,17 @@ public static class ShoppingListEndpoints
|
|||||||
{
|
{
|
||||||
FamilyId = familyId,
|
FamilyId = familyId,
|
||||||
ShoppingListId = listId,
|
ShoppingListId = listId,
|
||||||
// Phase 3 will give ShoppingListItem its own structured
|
Name = ing.Name,
|
||||||
// quantity/unit fields. Until then, fold the recipe's
|
|
||||||
// structured quantity into the free-form Name so the
|
|
||||||
// list still reads "2 cup flour" / "salt to taste".
|
|
||||||
Name = FormatIngredientForList(ing),
|
|
||||||
SortOrder = maxSort + idx + 1,
|
SortOrder = maxSort + idx + 1,
|
||||||
RecipeId = recipeId,
|
RecipeId = recipeId,
|
||||||
ProductId = ing.ProductId,
|
ProductId = ing.ProductId,
|
||||||
FamilyProductId = ing.FamilyProductId,
|
FamilyProductId = ing.FamilyProductId,
|
||||||
SectionId = rememberedSectionId,
|
SectionId = rememberedSectionId,
|
||||||
|
Quantity = ing.Quantity,
|
||||||
|
UnitOfMeasureId = ing.UnitOfMeasureId,
|
||||||
|
FamilyUnitOfMeasureId = ing.FamilyUnitOfMeasureId,
|
||||||
|
IsApproximate = ing.IsApproximate,
|
||||||
|
QuantityNote = ing.QuantityNote,
|
||||||
});
|
});
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
@@ -350,7 +379,7 @@ public static class ShoppingListEndpoints
|
|||||||
|
|
||||||
foreach (var item in newItems)
|
foreach (var item in newItems)
|
||||||
{
|
{
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title });
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipe.Title));
|
||||||
}
|
}
|
||||||
|
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
@@ -380,24 +409,25 @@ public static class ShoppingListEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Render an ingredient's structured quantity into a free-form display
|
/// Shape the wire payload for ItemAdded / Created responses so clients
|
||||||
/// string for use as a shopping list item Name in Phase 2. Phase 3 will
|
/// can render the structured quantity. Single source of truth so the
|
||||||
/// remove this once ShoppingListItem grows its own structured fields.
|
/// list endpoint and recipe-add fan-out stay aligned.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string FormatIngredientForList(RecipeIngredient ing)
|
private static object ItemAddedPayload(ShoppingListItem item, string? recipeTitle) => new
|
||||||
{
|
{
|
||||||
if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote))
|
item.Id,
|
||||||
return $"{ing.Name} ({ing.QuantityNote})";
|
item.Name,
|
||||||
|
item.SortOrder,
|
||||||
var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation;
|
item.SectionId,
|
||||||
if (ing.Quantity is { } q)
|
item.ProductId,
|
||||||
{
|
item.FamilyProductId,
|
||||||
// Trim trailing zeros so "2.0000" renders as "2".
|
item.Quantity,
|
||||||
var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
item.UnitOfMeasureId,
|
||||||
return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}";
|
item.FamilyUnitOfMeasureId,
|
||||||
}
|
item.IsApproximate,
|
||||||
return ing.Name;
|
item.QuantityNote,
|
||||||
}
|
RecipeTitle = recipeTitle,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the unit-of-measure FK pair on an ingredient/item payload.
|
/// Validates the unit-of-measure FK pair on an ingredient/item payload.
|
||||||
|
|||||||
@@ -119,10 +119,12 @@ public static class UnitEndpoints
|
|||||||
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
||||||
if (unit is null) return Results.NotFound();
|
if (unit is null) return Results.NotFound();
|
||||||
|
|
||||||
// Block deletion when any recipe ingredient still references the
|
// Block deletion when any recipe ingredient or shopping list item
|
||||||
// unit. (ShoppingListItem will gain the same check in Phase 3.)
|
// still references the unit.
|
||||||
var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
var inUseByRecipe = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||||
if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
|
if (inUseByRecipe) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
|
||||||
|
var inUseByList = await db.ShoppingListItems.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||||
|
if (inUseByList) return Results.Conflict(new { error = "Unit is in use by one or more shopping lists." });
|
||||||
|
|
||||||
db.FamilyUnitsOfMeasure.Remove(unit);
|
db.FamilyUnitsOfMeasure.Remove(unit);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|||||||
+1091
File diff suppressed because it is too large
Load Diff
+114
@@ -0,0 +1,114 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace YesChef.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStructuredShoppingListItemQuantities : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsApproximate",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "numeric(12,4)",
|
||||||
|
precision: 12,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "QuantityNote",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "FamilyUnitOfMeasureId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ShoppingListItems_UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "UnitOfMeasureId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "FamilyUnitOfMeasureId",
|
||||||
|
principalTable: "FamilyUnitsOfMeasure",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "UnitOfMeasureId",
|
||||||
|
principalTable: "UnitsOfMeasure",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ShoppingListItems_UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FamilyUnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsApproximate",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Quantity",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuantityNote",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UnitOfMeasureId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -514,6 +514,12 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int?>("FamilyProductId")
|
b.Property<int?>("FamilyProductId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyUnitOfMeasureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsApproximate")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsChecked")
|
b.Property<bool>("IsChecked")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -525,6 +531,14 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int?>("ProductId")
|
b.Property<int?>("ProductId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Quantity")
|
||||||
|
.HasPrecision(12, 4)
|
||||||
|
.HasColumnType("numeric(12,4)");
|
||||||
|
|
||||||
|
b.Property<string>("QuantityNote")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int?>("RecipeId")
|
b.Property<int?>("RecipeId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -543,6 +557,9 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("UnitOfMeasureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CheckedByUserId");
|
b.HasIndex("CheckedByUserId");
|
||||||
@@ -551,6 +568,8 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("FamilyProductId");
|
b.HasIndex("FamilyProductId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyUnitOfMeasureId");
|
||||||
|
|
||||||
b.HasIndex("ProductId");
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
b.HasIndex("RecipeId");
|
b.HasIndex("RecipeId");
|
||||||
@@ -561,6 +580,8 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ShoppingListId");
|
b.HasIndex("ShoppingListId");
|
||||||
|
|
||||||
|
b.HasIndex("UnitOfMeasureId");
|
||||||
|
|
||||||
b.ToTable("ShoppingListItems");
|
b.ToTable("ShoppingListItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -965,6 +986,11 @@ namespace YesChef.Api.Migrations
|
|||||||
.HasForeignKey("FamilyProductId")
|
.HasForeignKey("FamilyProductId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyUnitOfMeasureId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("YesChef.Api.Entities.Product", "Product")
|
b.HasOne("YesChef.Api.Entities.Product", "Product")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("ProductId")
|
.HasForeignKey("ProductId")
|
||||||
@@ -991,12 +1017,19 @@ namespace YesChef.Api.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UnitOfMeasureId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("CheckedByUser");
|
b.Navigation("CheckedByUser");
|
||||||
|
|
||||||
b.Navigation("Family");
|
b.Navigation("Family");
|
||||||
|
|
||||||
b.Navigation("FamilyProduct");
|
b.Navigation("FamilyProduct");
|
||||||
|
|
||||||
|
b.Navigation("FamilyUnitOfMeasure");
|
||||||
|
|
||||||
b.Navigation("Product");
|
b.Navigation("Product");
|
||||||
|
|
||||||
b.Navigation("Recipe");
|
b.Navigation("Recipe");
|
||||||
@@ -1006,6 +1039,8 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Navigation("Section");
|
b.Navigation("Section");
|
||||||
|
|
||||||
b.Navigation("ShoppingList");
|
b.Navigation("ShoppingList");
|
||||||
|
|
||||||
|
b.Navigation("UnitOfMeasure");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
import { startConnection, stopConnection } from '$lib/signalr';
|
import { startConnection, stopConnection } from '$lib/signalr';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||||
|
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
|
||||||
|
import { units } from '$lib/units.svelte';
|
||||||
|
import { formatQuantity } from '$lib/formatQuantity';
|
||||||
import type { HubConnection } from '@microsoft/signalr';
|
import type { HubConnection } from '@microsoft/signalr';
|
||||||
|
|
||||||
interface ListItem {
|
interface ListItem {
|
||||||
@@ -16,6 +19,11 @@
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
sectionId: number | null;
|
sectionId: number | null;
|
||||||
recipeTitle: string | null;
|
recipeTitle: string | null;
|
||||||
|
quantity: number | null;
|
||||||
|
unitOfMeasureId: number | null;
|
||||||
|
familyUnitOfMeasureId: number | null;
|
||||||
|
isApproximate: boolean;
|
||||||
|
quantityNote: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
@@ -42,6 +50,9 @@
|
|||||||
let newItemSectionId = $state<number | null>(null);
|
let newItemSectionId = $state<number | null>(null);
|
||||||
let newItemProductId = $state<number | null>(null);
|
let newItemProductId = $state<number | null>(null);
|
||||||
let newItemFamilyProductId = $state<number | null>(null);
|
let newItemFamilyProductId = $state<number | null>(null);
|
||||||
|
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
||||||
|
let newItemIsApproximate = $state(false);
|
||||||
|
let newItemQuantityNote = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let connection: HubConnection | null = null;
|
let connection: HubConnection | null = null;
|
||||||
|
|
||||||
@@ -83,12 +94,18 @@
|
|||||||
list = data;
|
list = data;
|
||||||
items = data.items;
|
items = data.items;
|
||||||
sections = data.sections;
|
sections = data.sections;
|
||||||
|
// Touch the unit catalog so abbreviations are available for display.
|
||||||
|
void units.all;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
connection = await startConnection();
|
connection = await startConnection();
|
||||||
await connection.invoke('JoinList', listId);
|
await connection.invoke('JoinList', listId);
|
||||||
|
|
||||||
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => {
|
connection.on('ItemAdded', (data: {
|
||||||
|
id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string;
|
||||||
|
quantity: number | null; unitOfMeasureId: number | null; familyUnitOfMeasureId: number | null;
|
||||||
|
isApproximate: boolean; quantityNote: string | null;
|
||||||
|
}) => {
|
||||||
if (!items.find((i) => i.id === data.id)) {
|
if (!items.find((i) => i.id === data.id)) {
|
||||||
items = [
|
items = [
|
||||||
...items,
|
...items,
|
||||||
@@ -99,7 +116,12 @@
|
|||||||
checkedByUserName: null,
|
checkedByUserName: null,
|
||||||
sortOrder: data.sortOrder,
|
sortOrder: data.sortOrder,
|
||||||
sectionId: data.sectionId ?? null,
|
sectionId: data.sectionId ?? null,
|
||||||
recipeTitle: data.recipeTitle ?? null
|
recipeTitle: data.recipeTitle ?? null,
|
||||||
|
quantity: data.quantity,
|
||||||
|
unitOfMeasureId: data.unitOfMeasureId,
|
||||||
|
familyUnitOfMeasureId: data.familyUnitOfMeasureId,
|
||||||
|
isApproximate: data.isApproximate,
|
||||||
|
quantityNote: data.quantityNote
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -154,12 +176,29 @@
|
|||||||
sortOrder: maxSort + 1,
|
sortOrder: maxSort + 1,
|
||||||
sectionId: newItemSectionId,
|
sectionId: newItemSectionId,
|
||||||
productId: newItemProductId,
|
productId: newItemProductId,
|
||||||
familyProductId: newItemFamilyProductId
|
familyProductId: newItemFamilyProductId,
|
||||||
|
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
|
||||||
|
unitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.unitOfMeasureId,
|
||||||
|
familyUnitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.familyUnitOfMeasureId,
|
||||||
|
isApproximate: newItemIsApproximate,
|
||||||
|
quantityNote: newItemIsApproximate ? (newItemQuantityNote || null) : null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
newItemName = '';
|
newItemName = '';
|
||||||
newItemProductId = null;
|
newItemProductId = null;
|
||||||
newItemFamilyProductId = null;
|
newItemFamilyProductId = null;
|
||||||
|
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||||
|
newItemIsApproximate = false;
|
||||||
|
newItemQuantityNote = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleApproximateNewItem() {
|
||||||
|
newItemIsApproximate = !newItemIsApproximate;
|
||||||
|
if (newItemIsApproximate) {
|
||||||
|
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||||
|
} else {
|
||||||
|
newItemQuantityNote = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onItemProductChange(product: ProductSuggestion | null) {
|
function onItemProductChange(product: ProductSuggestion | null) {
|
||||||
@@ -229,7 +268,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#if newItemIsApproximate}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newItemQuantityNote}
|
||||||
|
placeholder="e.g. to taste"
|
||||||
|
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<QuantityInput bind:value={newItemQuantity} />
|
||||||
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<ProductTypeahead
|
<ProductTypeahead
|
||||||
bind:value={newItemName}
|
bind:value={newItemName}
|
||||||
@@ -258,6 +308,14 @@
|
|||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleApproximateNewItem}
|
||||||
|
class="text-xs text-gray-500 hover:text-primary"
|
||||||
|
>
|
||||||
|
{newItemIsApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if uncheckedGroups.length > 0}
|
{#if uncheckedGroups.length > 0}
|
||||||
@@ -269,6 +327,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
{#each group.items as item (item.id)}
|
{#each group.items as item (item.id)}
|
||||||
|
{@const qty = formatQuantity(item, units.all)}
|
||||||
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleItem(item.id)}
|
onclick={() => toggleItem(item.id)}
|
||||||
@@ -276,6 +335,9 @@
|
|||||||
aria-label="Check {item.name}"
|
aria-label="Check {item.name}"
|
||||||
></button>
|
></button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if qty}
|
||||||
|
<span class="text-base font-medium text-primary">{qty}</span>
|
||||||
|
{/if}
|
||||||
<span class="text-base">{item.name}</span>
|
<span class="text-base">{item.name}</span>
|
||||||
{#if item.recipeTitle}
|
{#if item.recipeTitle}
|
||||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||||
@@ -324,6 +386,7 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
{#each group.items as item (item.id)}
|
{#each group.items as item (item.id)}
|
||||||
|
{@const qty = formatQuantity(item, units.all)}
|
||||||
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleItem(item.id)}
|
onclick={() => toggleItem(item.id)}
|
||||||
@@ -333,6 +396,9 @@
|
|||||||
✓
|
✓
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if qty}
|
||||||
|
<span class="text-base text-gray-400 line-through">{qty}</span>
|
||||||
|
{/if}
|
||||||
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
||||||
{#if item.checkedByUserName}
|
{#if item.checkedByUserName}
|
||||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user