Add structured quantities + units to recipe ingredients

Phase 2 of structured quantities + UoM. Replaces the free-form Quantity
string on RecipeIngredient with a structured (Quantity decimal, UnitOfMeasureId
or FamilyUnitOfMeasureId) pair, plus an IsApproximate + QuantityNote
escape hatch for "to taste" style entries. The unit FK pair mirrors the
existing Product / FamilyProduct pattern, with the same at-most-one and
tenant-scoping validation. Existing string Quantity values are dropped
per the agreed wipe-to-null migration plan.

Frontend ships a QuantityInput component (numeric field + unit dropdown
fed by a runes-cached effective catalog from /api/units) and a shared
formatter for read-only display. Recipe -> shopping list copy folds the
structured quantity into the item Name for now; Phase 3 will move the
fields onto ShoppingListItem directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-12 21:36:25 -05:00
parent 559d80c104
commit c7f9c31952
17 changed files with 1805 additions and 89 deletions
@@ -404,6 +404,12 @@ namespace YesChef.Api.Migrations
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<int?>("FamilyUnitOfMeasureId")
.HasColumnType("integer");
b.Property<bool>("IsApproximate")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -412,9 +418,13 @@ namespace YesChef.Api.Migrations
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
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")
.HasColumnType("integer");
@@ -422,16 +432,23 @@ namespace YesChef.Api.Migrations
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int?>("UnitOfMeasureId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("FamilyProductId");
b.HasIndex("FamilyUnitOfMeasureId");
b.HasIndex("ProductId");
b.HasIndex("RecipeId");
b.HasIndex("UnitOfMeasureId");
b.ToTable("RecipeIngredients");
});
@@ -869,6 +886,11 @@ namespace YesChef.Api.Migrations
.HasForeignKey("FamilyProductId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure")
.WithMany()
.HasForeignKey("FamilyUnitOfMeasureId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
@@ -880,13 +902,22 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure")
.WithMany()
.HasForeignKey("UnitOfMeasureId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("FamilyUnitOfMeasure");
b.Navigation("Product");
b.Navigation("Recipe");
b.Navigation("UnitOfMeasure");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>