From fc4f40beffb0400bbe629e3d8b363befb595081c Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Fri, 27 Feb 2026 13:10:02 -0600 Subject: [PATCH 01/15] feat: add recipe entities + refactor codebase --- .../Pages/Assignments/AdminAssignTask.razor | 9 +- .../Components/Pages/Recipes/Create.razor | 31 --- .../Components/Pages/Recipes/Edit.razor | 33 --- .../Components/Pages/Recipes/Index.razor | 5 - .../Components/Pages/Recipes/RecipeForm.razor | 234 ------------------ .../Components/Pages/Recipes/RecipeList.razor | 167 ------------- .../Components/Pages/Recipes/RecipeView.razor | 133 ---------- .../Pages/Recipes/RecipeView.razor.css | 10 - CulinaryCommandApp/Data/AppDbContext.cs | 111 ++++++++- .../Data/Entities/Ingredient.cs | 1 + CulinaryCommandApp/Data/Entities/Location.cs | 6 +- .../Data/Entities/MeasurementUnit.cs | 24 -- CulinaryCommandApp/Data/Entities/Recipe.cs | 37 --- .../Data/Entities/RecipeIngredient.cs | 37 --- .../Data/Entities/RecipeStep.cs | 25 -- CulinaryCommandApp/Data/Entities/Tasks.cs | 3 +- .../Data/Models/MeasurementUnitViewModel.cs | 15 -- .../Inventory/DTOs/CreateIngredientDTO.cs | 2 +- .../Inventory/DTOs/InventoryCatalogDTO.cs | 2 +- .../Inventory/DTOs/InventoryItemDTO.cs | 2 +- .../Configurations/IngredientConfiguration.cs | 2 +- .../Inventory/Entities/Ingredient.cs | 2 +- .../Inventory/Entities/InventoryBatch.cs | 2 +- .../Entities/InventoryTransaction.cs | 2 +- .../Inventory/Entities/LocationUnit.cs | 19 ++ CulinaryCommandApp/Inventory/Entities/Unit.cs | 6 +- .../Pages/Inventory/InventoryCatalog.razor | 2 +- .../Pages/Inventory/InventoryManagement.razor | 8 +- .../Inventory/Services/IngredientService.cs | 6 +- .../Services/Interfaces/IIngredientService.cs | 4 +- .../Interfaces/IInventoryManagementService.cs | 4 +- .../IInventoryTransactionService.cs | 4 +- .../Services/Interfaces/IUnitService.cs | 9 +- .../Services/InventoryManagementService.cs | 10 +- .../Services/InventoryTransactionService.cs | 6 +- .../Inventory/Services/UnitService.cs | 46 +++- CulinaryCommandApp/Program.cs | 5 +- .../Entities/PurchaseOrderLine.cs | 2 +- .../PurchaseOrder/Pages/Create.razor | 2 +- CulinaryCommandApp/Recipe/Entities/Recipe.cs | 40 +++ .../Recipe/Entities/RecipeIngredient.cs | 30 +++ .../Recipe/Entities/RecipeStep.cs | 32 +++ .../Recipe/Entities/RecipeSubRecipe.cs | 12 + .../{ => Recipe}/Services/RecipeService.cs | 14 +- .../DTOs/CreateIngredientDTOTests.cs | 3 +- .../DTOs/InventoryCatalogDTOTests.cs | 2 +- .../Inventory/DTOs/InventoryItemDTOTests.cs | 2 +- .../Inventory/Entities/IngredientTest.cs | 2 +- .../Entities/InventoryTransactionTest.cs | 2 +- .../Inventory/Entities/UnitTest.cs | 2 +- 50 files changed, 347 insertions(+), 822 deletions(-) delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/Create.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/Edit.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/Index.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor delete mode 100644 CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor.css delete mode 100644 CulinaryCommandApp/Data/Entities/MeasurementUnit.cs delete mode 100644 CulinaryCommandApp/Data/Entities/Recipe.cs delete mode 100644 CulinaryCommandApp/Data/Entities/RecipeIngredient.cs delete mode 100644 CulinaryCommandApp/Data/Entities/RecipeStep.cs delete mode 100644 CulinaryCommandApp/Data/Models/MeasurementUnitViewModel.cs create mode 100644 CulinaryCommandApp/Inventory/Entities/LocationUnit.cs create mode 100644 CulinaryCommandApp/Recipe/Entities/Recipe.cs create mode 100644 CulinaryCommandApp/Recipe/Entities/RecipeIngredient.cs create mode 100644 CulinaryCommandApp/Recipe/Entities/RecipeStep.cs create mode 100644 CulinaryCommandApp/Recipe/Entities/RecipeSubRecipe.cs rename CulinaryCommandApp/{ => Recipe}/Services/RecipeService.cs (79%) diff --git a/CulinaryCommandApp/Components/Pages/Assignments/AdminAssignTask.razor b/CulinaryCommandApp/Components/Pages/Assignments/AdminAssignTask.razor index 3b73c89..3e1de98 100644 --- a/CulinaryCommandApp/Components/Pages/Assignments/AdminAssignTask.razor +++ b/CulinaryCommandApp/Components/Pages/Assignments/AdminAssignTask.razor @@ -4,6 +4,8 @@ @using CulinaryCommand.Services @using CulinaryCommand.Data.Enums; @using CulinaryCommand.Services.UserContextSpace +@using CulinaryCommandApp.Recipe.Services; +@using CulinaryCommandApp.Recipe.Entities; @inject IUserContextService UserCtx @inject NavigationManager Nav @inject ILocationService LocationService @@ -346,11 +348,8 @@ else try { - var all = await RecipeService.GetAllAsync(); - recipes = all - .Where(r => r.LocationId == selectedLocationId.Value) - .OrderBy(r => r.Title) - .ToList(); + recipes = await RecipeService.GetAllByLocationIdAsync(selectedLocationId.Value); + recipes = recipes.OrderBy(r => r.Title).ToList(); } catch { diff --git a/CulinaryCommandApp/Components/Pages/Recipes/Create.razor b/CulinaryCommandApp/Components/Pages/Recipes/Create.razor deleted file mode 100644 index 8df7d49..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/Create.razor +++ /dev/null @@ -1,31 +0,0 @@ -@page "/recipes/create" -@using CulinaryCommand.Data.Entities -@inject NavigationManager Nav -@inject RecipeService Recipes -@inject LocationState LocationState -@rendermode InteractiveServer - -

Create Recipe

- - - -@code { - private Recipe model = new(); - - private async Task Save() - { - // Make sure we have a current location - if (LocationState.CurrentLocation is null) - { - // You can swap this for a nicer UI message later - Console.WriteLine("No current location selected – cannot save recipe."); - return; - } - - // πŸ”₯ attach recipe to the active location - model.LocationId = LocationState.CurrentLocation.Id; - - await Recipes.CreateAsync(model); - Nav.NavigateTo("/recipes"); - } -} \ No newline at end of file diff --git a/CulinaryCommandApp/Components/Pages/Recipes/Edit.razor b/CulinaryCommandApp/Components/Pages/Recipes/Edit.razor deleted file mode 100644 index 95b1205..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/Edit.razor +++ /dev/null @@ -1,33 +0,0 @@ -@page "/recipes/edit/{id:int}" -@using CulinaryCommand.Data.Entities -@inject NavigationManager Nav -@inject RecipeService Recipes -@rendermode InteractiveServer - -

Edit Recipe

- -@if (model == null) -{ -

Loading...

-} -else -{ - -} - -@code { - [Parameter] public int id { get; set; } - - private Recipe? model; - - protected override async Task OnInitializedAsync() - { - model = await Recipes.GetByIdAsync(id); - } - - private async Task Save() - { - await Recipes.UpdateAsync(model); - Nav.NavigateTo("/recipes"); - } -} diff --git a/CulinaryCommandApp/Components/Pages/Recipes/Index.razor b/CulinaryCommandApp/Components/Pages/Recipes/Index.razor deleted file mode 100644 index 7da5afe..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/Index.razor +++ /dev/null @@ -1,5 +0,0 @@ -@page "/recipes" -@using CulinaryCommand.Data.Entities -@rendermode InteractiveServer - - diff --git a/CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor b/CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor deleted file mode 100644 index bc08ae7..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor +++ /dev/null @@ -1,234 +0,0 @@ -@using CulinaryCommand.Data.Entities -@using CulinaryCommand.Inventory.Services -@rendermode InteractiveServer -@inject NavigationManager Nav -@inject IngredientService Ingredients -@inject UnitService Units -@inject EnumService enumService - -
-
-

@FormTitle

-
- -
- - -
-
- - -
- -
- - -
-
- -
-
- - -
- -
- - - Enter a value from 0–100% -
- -
- - -
-
- -
- - -
Ingredients
- - @foreach (var item in Model.RecipeIngredients.OrderBy(i => i.SortOrder)) - { -
- - -
- - -
- - -
- - -
- - -
- - -
- -
-
- - -
-
- - -
-
- - -
- } - - - -
- - -
Steps
- - @foreach (var step in Model.Steps.OrderBy(s => s.StepNumber)) - { -
- - - -
- } - - - -
- - - - - -
-
- -@code { - [Parameter] public Recipe Model { get; set; } = new(); - [Parameter] public EventCallback OnValidSubmit { get; set; } - [Parameter] public string FormTitle { get; set; } = "Recipe"; - - public List IngredientCategories { get; set; } = new(); - private List recipeCategories = new (); - private List recipeTypes = new (); - private List yieldUnits = new(); - - protected override async Task OnInitializedAsync() - { - IngredientCategories = enumService.GetCategories(); - recipeCategories = enumService.GetCategories(); - recipeTypes = enumService.GetRecipeTypes(); - yieldUnits = enumService.GetUnits(); - } - - void AddIngredient() - { - Model.RecipeIngredients.Add(new RecipeIngredient - { - SortOrder = Model.RecipeIngredients.Count + 1, - AvailableIngredients = new(), - AvailableUnits = new() - }); - } - - void RemoveIngredient(RecipeIngredient item) - { - Model.RecipeIngredients.Remove(item); - } - - void AddStep() - { - Model.Steps.Add(new RecipeStep - { - StepNumber = Model.Steps.Count + 1 - }); - } - - void RemoveStep(RecipeStep step) - { - Model.Steps.Remove(step); - } - - async Task LoadIngredientsForCategory(RecipeIngredient item, string? category) - { - if (string.IsNullOrWhiteSpace(category)) - return; - - item.AvailableIngredients = await Ingredients.GetByCategoryAsync(category); - } - - async Task LoadUnitsForIngredient(RecipeIngredient item) - { - if (item.IngredientId <= 0) - return; - - item.AvailableUnits = await Units.GetUnitsForIngredient(item.IngredientId); - } - - void Cancel() => Nav.NavigateTo("/recipes"); - - async Task Save() => await OnValidSubmit.InvokeAsync(); -} \ No newline at end of file diff --git a/CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor b/CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor deleted file mode 100644 index 4705381..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor +++ /dev/null @@ -1,167 +0,0 @@ -@* @page "/recipes" *@ -@rendermode InteractiveServer -@implements IDisposable - -@using CulinaryCommand.Data.Entities -@using CulinaryCommand.Services -@using CulinaryCommand.Services.UserContextSpace - -@inject NavigationManager Nav -@inject IJSRuntime Js -@inject RecipeService Recipes -@inject LocationState LocationState -@inject IUserContextService UserCtx - -

Recipes

- -@if (priv) -{ - -} - -@if (recipes == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - - @foreach (var r in recipes) - { - - - - - - - - } - -
TitleCategoryTypeYield
@r.Title@r.Category@r.RecipeType@FormatYield(r) - - @if (priv) - { - - - } -
-} - -@code { - private UserContext? _ctx; - - private List? recipes; - private bool priv = false; - - protected override async Task OnInitializedAsync() - { - _ctx = await UserCtx.GetAsync(); - - // If not authenticated, bounce to Cognito login - if (_ctx.IsAuthenticated != true) - { - Nav.NavigateTo("/login", true); - return; - } - - // Invite-only: authenticated but not in DB - if (_ctx.User is null) - { - Nav.NavigateTo("/no-access", true); - return; - } - - // Ensure LocationState is hydrated if user deep-links here - if (LocationState.ManagedLocations.Count == 0 && _ctx.AccessibleLocations.Any()) - await LocationState.SetLocationsAsync(_ctx.AccessibleLocations); - - UpdatePriv(); - - LocationState.OnChange += RefreshRecipes; - - await LoadRecipesAsync(); - } - - private void UpdatePriv() - { - var role = _ctx?.User?.Role; - priv = - string.Equals(role, "Manager", StringComparison.OrdinalIgnoreCase) || - string.Equals(role, "Admin", StringComparison.OrdinalIgnoreCase); - } - - private async Task LoadRecipesAsync() - { - var loc = LocationState.CurrentLocation; - - if (loc == null) - { - recipes = new List(); - return; - } - - recipes = await Recipes.GetAllByLocationIdAsync(loc.Id); - } - - private void RefreshRecipes() - { - _ = InvokeAsync(async () => - { - await LoadRecipesAsync(); - StateHasChanged(); - }); - } - - void AddNew() => Nav.NavigateTo("/recipes/create"); - void Edit(int id) => Nav.NavigateTo($"/recipes/edit/{id}"); - void View(int id) => Nav.NavigateTo($"/recipes/view/{id}"); - - async Task Delete(int id) - { - if (await Js.InvokeAsync("confirm", $"Delete recipe #{id}?")) - { - await Recipes.DeleteAsync(id); - await LoadRecipesAsync(); - await InvokeAsync(StateHasChanged); - } - } - - private string FormatYield(Recipe r) - { - if (!r.YieldAmount.HasValue) - return string.IsNullOrWhiteSpace(r.YieldUnit) ? string.Empty : r.YieldUnit!; - - var amount = r.YieldAmount.Value; - - var amountText = decimal.Truncate(amount) == amount - ? amount.ToString("0") - : amount.ToString("0.##"); - - return string.IsNullOrWhiteSpace(r.YieldUnit) - ? amountText - : $"{amountText} {r.YieldUnit}"; - } - - public void Dispose() - { - LocationState.OnChange -= RefreshRecipes; - } -} diff --git a/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor b/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor deleted file mode 100644 index ba7b904..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor +++ /dev/null @@ -1,133 +0,0 @@ -@page "/recipes/view/{id:int}" -@using CulinaryCommand.Data.Entities -@inject RecipeService Recipes -@inject NavigationManager Nav - -
- -
-

@recipe?.Title

- -
- - @if (recipe == null) - { -

Loading...

- } - else - { -
-
- - -
-
-
-
Category
-
@recipe.Category
-
-
- -
-
-
Type
-
@recipe.RecipeType
-
-
- -
-
-
Yield
-
@FormatYield(recipe)
-
-
-
- -
- - -

- Ingredients -

- - @if (recipe.RecipeIngredients.Count == 0) - { -

No ingredients added.

- } - else - { -
    - @foreach (var ing in recipe.RecipeIngredients.OrderBy(i => i.SortOrder)) - { -
  • - @FormatQuantity(ing.Quantity) - @(" " + ing.Unit?.Name) - @(" β€” ") - @ing.Ingredient?.Name - - @if (!string.IsNullOrWhiteSpace(ing.PrepNote)) - { -
    @ing.PrepNote
    - } -
  • - } -
- } - - -

- Steps -

- - @if (recipe.Steps.Count == 0) - { -

No steps added.

- } - else - { -
    - @foreach (var step in recipe.Steps.OrderBy(s => s.StepNumber)) - { -
  1. -
    @step.Instructions
    -
  2. - } -
- } - -
-
- } -
- -@code { - [Parameter] public int id { get; set; } - private Recipe? recipe; - - protected override async Task OnInitializedAsync() - { - recipe = await Recipes.GetByIdAsync(id); - } - - void GoBack() => Nav.NavigateTo("/recipes"); - - string FormatQuantity(decimal qty) - { - // remove trailing zeros - return qty.ToString("0.##"); - } - - string FormatYield(Recipe r) - { - if (!r.YieldAmount.HasValue) - return r.YieldUnit ?? ""; - - string amt = r.YieldAmount.Value.ToString("0.##"); - - return string.IsNullOrWhiteSpace(r.YieldUnit) - ? amt - : $"{amt} {r.YieldUnit}"; - } -} diff --git a/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor.css b/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor.css deleted file mode 100644 index 4669abd..0000000 --- a/CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor.css +++ /dev/null @@ -1,10 +0,0 @@ -.detail-box { - background: #f8f9fa; - border: 1px solid #e3e3e3; -} - -.list-group-numbered > .list-group-item { - padding: 1rem; - border-radius: 6px; - margin-bottom: 6px; -} diff --git a/CulinaryCommandApp/Data/AppDbContext.cs b/CulinaryCommandApp/Data/AppDbContext.cs index c37eb03..1de3a0e 100644 --- a/CulinaryCommandApp/Data/AppDbContext.cs +++ b/CulinaryCommandApp/Data/AppDbContext.cs @@ -1,9 +1,11 @@ using Microsoft.EntityFrameworkCore; using CulinaryCommand.Data.Entities; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; +using CulinaryCommandApp.Recipe.Entities; using PO = CulinaryCommand.PurchaseOrder.Entities; using V = CulinaryCommand.Vendor.Entities; + namespace CulinaryCommand.Data { public class AppDbContext : DbContext @@ -17,20 +19,20 @@ public AppDbContext(DbContextOptions options) public DbSet Users => Set(); public DbSet Tasks => Set(); public DbSet Companies => Set(); - public DbSet Ingredients => Set(); - public DbSet MeasurementUnits => Set(); - public DbSet Recipes => Set(); - public DbSet RecipeIngredients => Set(); - public DbSet RecipeSteps => Set(); + public DbSet Ingredients => Set(); + public DbSet Recipes => Set(); + public DbSet RecipeIngredients => Set(); + public DbSet RecipeSteps => Set(); + public DbSet RecipeSubRecipes => Set(); public DbSet UserLocations => Set(); public DbSet ManagerLocations => Set(); public DbSet InventoryTransactions => Set(); - public DbSet Units => Set(); + public DbSet Units => Set(); public DbSet PurchaseOrders => Set(); public DbSet PurchaseOrderLines => Set(); public DbSet Vendors => Set(); public DbSet LocationVendors => Set(); - + public DbSet LocationUnits => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -80,14 +82,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(ml => ml.LocationId); // Ingredient belongs to a Location - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(i => i.Location) .WithMany() .HasForeignKey(i => i.LocationId) .OnDelete(DeleteBehavior.Cascade); // Ingredient optionally belongs to a Vendor - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(i => i.Vendor) .WithMany() .HasForeignKey(i => i.VendorId) @@ -114,7 +116,94 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasOne(lv => lv.Vendor) .WithMany(v => v.LocationVendors) .HasForeignKey(lv => lv.VendorId); - + + // Recipe belongs to a Location + modelBuilder.Entity() + .HasOne(r => r.Location) + .WithMany() + .HasForeignKey(r => r.LocationId) + .OnDelete(DeleteBehavior.Cascade); + + // Tasks optionally references a Recipe (prep task) + modelBuilder.Entity() + .HasOne(t => t.Recipe) + .WithMany() + .HasForeignKey(t => t.RecipeId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + // RecipeIngredient to parent Recipe + modelBuilder.Entity() + .HasOne(ri => ri.Recipe) + .WithMany(r => r.RecipeIngredients) + .HasForeignKey(ri => ri.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + + // RecipeIngredient - optional Ingredient (raw ingredient line) + modelBuilder.Entity() + .HasOne(ri => ri.Ingredient) + .WithMany() + .HasForeignKey(ri => ri.IngredientId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // RecipeIngredient - optional sub-Recipe + modelBuilder.Entity() + .HasOne(ri => ri.SubRecipe) + .WithMany() + .HasForeignKey(ri => ri.SubRecipeId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // RecipeIngredient - Unit + modelBuilder.Entity() + .HasOne(ri => ri.Unit) + .WithMany() + .HasForeignKey(ri => ri.UnitId) + .OnDelete(DeleteBehavior.Restrict); + + // RecipeStep - Recipe + modelBuilder.Entity() + .HasOne(rs => rs.Recipe) + .WithMany(r => r.Steps) + .HasForeignKey(rs => rs.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + + // RecipeSubRecipe: composite PK + modelBuilder.Entity() + .HasKey(rs => new { rs.ParentRecipeId, rs.ChildRecipeId }); + + // RecipeSubRecipe - parent Recipe (cascade: deleting a parent removes its sub-recipe links) + modelBuilder.Entity() + .HasOne(rs => rs.ParentRecipe) + .WithMany(r => r.SubRecipeUsages) + .HasForeignKey(rs => rs.ParentRecipeId) + .OnDelete(DeleteBehavior.Cascade); + + // RecipeSubRecipe - child Recipe (restrict: cannot delete a sub-recipe still in use) + modelBuilder.Entity() + .HasOne(rs => rs.ChildRecipe) + .WithMany(r => r.UsedInRecipes) + .HasForeignKey(rs => rs.ChildRecipeId) + .OnDelete(DeleteBehavior.Restrict); + + ConfigureLocationUnit(modelBuilder); + } + + private void ConfigureLocationUnit(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(lu => new { lu.LocationId, lu.UnitId }); + + modelBuilder.Entity() + .HasOne(lu => lu.Location) + .WithMany(l => l.LocationUnits) + .HasForeignKey(lu => lu.LocationId); + + modelBuilder.Entity() + .HasOne(lu => lu.Unit) + .WithMany(u => u.LocationUnits) + .HasForeignKey(lu => lu.UnitId); } } } \ No newline at end of file diff --git a/CulinaryCommandApp/Data/Entities/Ingredient.cs b/CulinaryCommandApp/Data/Entities/Ingredient.cs index 944da5b..c78b7ea 100644 --- a/CulinaryCommandApp/Data/Entities/Ingredient.cs +++ b/CulinaryCommandApp/Data/Entities/Ingredient.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; +using CulinaryCommandApp.Recipe.Entities; namespace CulinaryCommand.Data.Entities { diff --git a/CulinaryCommandApp/Data/Entities/Location.cs b/CulinaryCommandApp/Data/Entities/Location.cs index 8d84094..96b230d 100644 --- a/CulinaryCommandApp/Data/Entities/Location.cs +++ b/CulinaryCommandApp/Data/Entities/Location.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using CulinaryCommandApp.Inventory.Entities; +using CulinaryCommandApp.Recipe.Entities; namespace CulinaryCommand.Data.Entities { @@ -53,6 +55,8 @@ public class Location [JsonIgnore] public ICollection LocationVendors { get; set; } = new List(); - } + [JsonIgnore] + public ICollection LocationUnits { get; set; } = new List(); + } } diff --git a/CulinaryCommandApp/Data/Entities/MeasurementUnit.cs b/CulinaryCommandApp/Data/Entities/MeasurementUnit.cs deleted file mode 100644 index 5413550..0000000 --- a/CulinaryCommandApp/Data/Entities/MeasurementUnit.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; - -namespace CulinaryCommand.Data.Entities -{ - public class MeasurementUnit - { - [Key] - public int UnitId { get; set; } - - [Required, MaxLength(128)] - public string Name { get; set; } // "Cup" - - [Required, MaxLength(32)] - public string Abbreviation { get; set; } // "c" - - // Navigation - public ICollection RecipeIngredients { get; set; } = new List(); - } - -} \ No newline at end of file diff --git a/CulinaryCommandApp/Data/Entities/Recipe.cs b/CulinaryCommandApp/Data/Entities/Recipe.cs deleted file mode 100644 index 60b15a8..0000000 --- a/CulinaryCommandApp/Data/Entities/Recipe.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; - -namespace CulinaryCommand.Data.Entities -{ - public class Recipe - { - [Key] - public int RecipeId { get; set; } - public int LocationId { get; set; } - - [Required, MaxLength(128)] - public string? Title { get; set; } - - [Required, MaxLength(128)] - public string Category { get; set; } - - [Required, MaxLength(128)] - public string RecipeType { get; set; } - - public decimal? YieldAmount { get; set; } - - [MaxLength(128)] - public string YieldUnit { get; set; } - - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - // Navigation - public Location? Location { get; set; } - public ICollection RecipeIngredients { get; set; } = new List(); - public ICollection Steps { get; set; } = new List(); - } - -} \ No newline at end of file diff --git a/CulinaryCommandApp/Data/Entities/RecipeIngredient.cs b/CulinaryCommandApp/Data/Entities/RecipeIngredient.cs deleted file mode 100644 index d166a1b..0000000 --- a/CulinaryCommandApp/Data/Entities/RecipeIngredient.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using System.Threading.Tasks; -using CulinaryCommand.Inventory.Entities; - -namespace CulinaryCommand.Data.Entities -{ - public class RecipeIngredient - { - [Key] - public int RecipeIngredientId { get; set; } - public int RecipeId { get; set; } - public int IngredientId { get; set; } - public int UnitId { get; set; } - - public decimal Quantity { get; set; } - - [MaxLength(256)] - public string? PrepNote { get; set; } - public int SortOrder { get; set; } - - // Navigation - public Recipe? Recipe { get; set; } - public CulinaryCommand.Inventory.Entities.Ingredient? Ingredient { get; set; } - public Unit? Unit { get; set; } - - [NotMapped] - public List AvailableIngredients { get; set; } = new(); - - [NotMapped] - public List AvailableUnits { get; set; } = new(); - } - -} \ No newline at end of file diff --git a/CulinaryCommandApp/Data/Entities/RecipeStep.cs b/CulinaryCommandApp/Data/Entities/RecipeStep.cs deleted file mode 100644 index 8a25b2e..0000000 --- a/CulinaryCommandApp/Data/Entities/RecipeStep.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; - -namespace CulinaryCommand.Data.Entities -{ - public class RecipeStep - { - [Key] - public int StepId { get; set; } - public int RecipeId { get; set; } - - public int StepNumber { get; set; } // 1, 2, 3... - - [MaxLength(256)] - public string? Instructions { get; set; } - - // Navigation - public Recipe? Recipe { get; set; } - } - -} \ No newline at end of file diff --git a/CulinaryCommandApp/Data/Entities/Tasks.cs b/CulinaryCommandApp/Data/Entities/Tasks.cs index 4e5d78a..0c18781 100644 --- a/CulinaryCommandApp/Data/Entities/Tasks.cs +++ b/CulinaryCommandApp/Data/Entities/Tasks.cs @@ -1,7 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using CulinaryCommand.Data.Enums; -using InvIngredient = CulinaryCommand.Inventory.Entities.Ingredient; +using CulinaryCommandApp.Recipe.Entities; +using InvIngredient = CulinaryCommandApp.Inventory.Entities.Ingredient; namespace CulinaryCommand.Data.Entities { diff --git a/CulinaryCommandApp/Data/Models/MeasurementUnitViewModel.cs b/CulinaryCommandApp/Data/Models/MeasurementUnitViewModel.cs deleted file mode 100644 index d311c2f..0000000 --- a/CulinaryCommandApp/Data/Models/MeasurementUnitViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace CulinaryCommand.Models -{ - public class MeasurementUnitViewModel - { - public int UnitId { get; set; } - public string UnitName { get; set; } = ""; - public string Abbreviation { get; set; } = ""; - } - -} \ No newline at end of file diff --git a/CulinaryCommandApp/Inventory/DTOs/CreateIngredientDTO.cs b/CulinaryCommandApp/Inventory/DTOs/CreateIngredientDTO.cs index 84abe90..4443575 100644 --- a/CulinaryCommandApp/Inventory/DTOs/CreateIngredientDTO.cs +++ b/CulinaryCommandApp/Inventory/DTOs/CreateIngredientDTO.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace CulinaryCommand.Inventory.DTOs +namespace CulinaryCommandApp.Inventory.DTOs { public class CreateIngredientDTO { diff --git a/CulinaryCommandApp/Inventory/DTOs/InventoryCatalogDTO.cs b/CulinaryCommandApp/Inventory/DTOs/InventoryCatalogDTO.cs index 0aaf14d..ac8398f 100644 --- a/CulinaryCommandApp/Inventory/DTOs/InventoryCatalogDTO.cs +++ b/CulinaryCommandApp/Inventory/DTOs/InventoryCatalogDTO.cs @@ -1,6 +1,6 @@ using System; -namespace CulinaryCommand.Inventory.DTOs +namespace CulinaryCommandApp.Inventory.DTOs { public class InventoryCatalogDTO { diff --git a/CulinaryCommandApp/Inventory/DTOs/InventoryItemDTO.cs b/CulinaryCommandApp/Inventory/DTOs/InventoryItemDTO.cs index 0304346..8684687 100644 --- a/CulinaryCommandApp/Inventory/DTOs/InventoryItemDTO.cs +++ b/CulinaryCommandApp/Inventory/DTOs/InventoryItemDTO.cs @@ -1,6 +1,6 @@ using System; -namespace CulinaryCommand.Inventory.DTOs +namespace CulinaryCommandApp.Inventory.DTOs { public class InventoryItemDTO { diff --git a/CulinaryCommandApp/Inventory/Data/Configurations/IngredientConfiguration.cs b/CulinaryCommandApp/Inventory/Data/Configurations/IngredientConfiguration.cs index c4f06b7..d4cb11a 100644 --- a/CulinaryCommandApp/Inventory/Data/Configurations/IngredientConfiguration.cs +++ b/CulinaryCommandApp/Inventory/Data/Configurations/IngredientConfiguration.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; namespace CulinaryCommand.Inventory.Data.Configurations { diff --git a/CulinaryCommandApp/Inventory/Entities/Ingredient.cs b/CulinaryCommandApp/Inventory/Entities/Ingredient.cs index 11df568..a58d097 100644 --- a/CulinaryCommandApp/Inventory/Entities/Ingredient.cs +++ b/CulinaryCommandApp/Inventory/Entities/Ingredient.cs @@ -1,7 +1,7 @@ using System; using CulinaryCommand.Data.Entities; -namespace CulinaryCommand.Inventory.Entities +namespace CulinaryCommandApp.Inventory.Entities { public class Ingredient { diff --git a/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs b/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs index 57c3a09..ff34b02 100644 --- a/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs +++ b/CulinaryCommandApp/Inventory/Entities/InventoryBatch.cs @@ -2,7 +2,7 @@ using CulinaryCommand.Data.Entities; -namespace CulinaryCommand.Inventory.Entities +namespace CulinaryCommandApp.Inventory.Entities { public class InventoryBatch { diff --git a/CulinaryCommandApp/Inventory/Entities/InventoryTransaction.cs b/CulinaryCommandApp/Inventory/Entities/InventoryTransaction.cs index 3f327d3..4ed294d 100644 --- a/CulinaryCommandApp/Inventory/Entities/InventoryTransaction.cs +++ b/CulinaryCommandApp/Inventory/Entities/InventoryTransaction.cs @@ -1,6 +1,6 @@ using System; -namespace CulinaryCommand.Inventory.Entities +namespace CulinaryCommandApp.Inventory.Entities { public class InventoryTransaction diff --git a/CulinaryCommandApp/Inventory/Entities/LocationUnit.cs b/CulinaryCommandApp/Inventory/Entities/LocationUnit.cs new file mode 100644 index 0000000..7fe3928 --- /dev/null +++ b/CulinaryCommandApp/Inventory/Entities/LocationUnit.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using CulinaryCommand.Data.Entities; +using CulinaryCommandApp.Inventory.Entities; + +namespace CulinaryCommandApp.Inventory.Entities +{ + public class LocationUnit + { + [JsonIgnore] + public Location Location { get; set; } = default!; + public int LocationId { get; set; } + + [JsonIgnore] + public Unit Unit { get; set; } = default!; + public int UnitId { get; set; } + + public DateTime AssignedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Inventory/Entities/Unit.cs b/CulinaryCommandApp/Inventory/Entities/Unit.cs index 171f42a..676f666 100644 --- a/CulinaryCommandApp/Inventory/Entities/Unit.cs +++ b/CulinaryCommandApp/Inventory/Entities/Unit.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; -namespace CulinaryCommand.Inventory.Entities +namespace CulinaryCommandApp.Inventory.Entities { public class Unit { @@ -22,5 +23,8 @@ public class Unit // list of inventory transactions that use this unit public ICollection InventoryTransaction { get; set; } = new List(); + [JsonIgnore] + public ICollection LocationUnits { get; set; } = new List(); + } } \ No newline at end of file diff --git a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor index c8efc8b..7dc51b4 100644 --- a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor +++ b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor @@ -1,7 +1,7 @@ @page "/inventory-catalog" @rendermode InteractiveServer -@using CulinaryCommand.Inventory.DTOs +@using CulinaryCommandApp.Inventory.DTOs
diff --git a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryManagement.razor b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryManagement.razor index e24b7c3..eae930e 100644 --- a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryManagement.razor +++ b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryManagement.razor @@ -2,10 +2,10 @@ @rendermode InteractiveServer @implements IDisposable -@using CulinaryCommand.Inventory.DTOs -@using CulinaryCommand.Inventory.Entities -@using CulinaryCommand.Inventory.Services -@using CulinaryCommand.Inventory.Services.Interfaces +@using CulinaryCommandApp.Inventory.DTOs +@using CulinaryCommandApp.Inventory.Entities +@using CulinaryCommandApp.Inventory.Services +@using CulinaryCommandApp.Inventory.Services.Interfaces @using Microsoft.JSInterop @using Microsoft.AspNetCore.Components.Forms diff --git a/CulinaryCommandApp/Inventory/Services/IngredientService.cs b/CulinaryCommandApp/Inventory/Services/IngredientService.cs index c784eda..5453502 100644 --- a/CulinaryCommandApp/Inventory/Services/IngredientService.cs +++ b/CulinaryCommandApp/Inventory/Services/IngredientService.cs @@ -2,11 +2,11 @@ using System.Threading; using System.Threading.Tasks; using CulinaryCommand.Data; -using CulinaryCommand.Inventory.Entities; -using CulinaryCommand.Inventory.Services.Interfaces; +using CulinaryCommandApp.Inventory.Entities; +using CulinaryCommandApp.Inventory.Services.Interfaces; using Microsoft.EntityFrameworkCore; -namespace CulinaryCommand.Inventory.Services +namespace CulinaryCommandApp.Inventory.Services { public class IngredientService : IIngredientService { diff --git a/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs b/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs index 4f7bccf..120e633 100644 --- a/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs +++ b/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; -namespace CulinaryCommand.Inventory.Services.Interfaces +namespace CulinaryCommandApp.Inventory.Services.Interfaces { public interface IIngredientService { diff --git a/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryManagementService.cs b/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryManagementService.cs index 2f12c69..5cc7131 100644 --- a/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryManagementService.cs +++ b/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryManagementService.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; -using CulinaryCommand.Inventory.DTOs; +using CulinaryCommandApp.Inventory.DTOs; -namespace CulinaryCommand.Inventory.Services.Interfaces +namespace CulinaryCommandApp.Inventory.Services.Interfaces { public interface IInventoryManagementService { diff --git a/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryTransactionService.cs b/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryTransactionService.cs index 7f6e23e..7e83f8b 100644 --- a/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryTransactionService.cs +++ b/CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryTransactionService.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; -namespace CulinaryCommand.Inventory.Services.Interfaces +namespace CulinaryCommandApp.Inventory.Services.Interfaces { public interface IInventoryTransactionService { diff --git a/CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs b/CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs index 9bff6c2..4e54fbb 100644 --- a/CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs +++ b/CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs @@ -1,15 +1,20 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; -namespace CulinaryCommand.Inventory.Services.Interfaces +namespace CulinaryCommandApp.Inventory.Services.Interfaces { public interface IUnitService { // Returns all units asynchronously as a list of Unit objects. Task> GetAllAsync(CancellationToken cancellationToken = default); + // returns only the units enabled for a specific location + Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default); + + Task SetLocationUnitsAsync(int locationId, IEnumerable unitIds, CancellationToken cancellationToken = default); + // Retrieves a unit by its Id asynchronously; returns null if not found. Task GetByIdAsync(int id, CancellationToken cancellationToken = default); diff --git a/CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs b/CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs index 8850e99..51cd4fb 100644 --- a/CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs +++ b/CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs @@ -1,11 +1,11 @@ using System.Linq; -using CulinaryCommand.Inventory.Services.Interfaces; +using CulinaryCommandApp.Inventory.Services.Interfaces; using Microsoft.EntityFrameworkCore; -using CulinaryCommand.Inventory.DTOs; +using CulinaryCommandApp.Inventory.DTOs; using CulinaryCommand.Data; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; -namespace CulinaryCommand.Inventory.Services +namespace CulinaryCommandApp.Inventory.Services { public class InventoryManagementService : IInventoryManagementService { @@ -76,7 +76,7 @@ public async Task> GetCategoriesByLocationAsync(int locationId) } public async Task AddItemAsync(CreateIngredientDTO dto) { - var entity = new CulinaryCommand.Inventory.Entities.Ingredient { + var entity = new CulinaryCommandApp.Inventory.Entities.Ingredient { Name = dto.Name, Sku = dto.SKU, Price = dto.Price, diff --git a/CulinaryCommandApp/Inventory/Services/InventoryTransactionService.cs b/CulinaryCommandApp/Inventory/Services/InventoryTransactionService.cs index 7b785e1..16449fe 100644 --- a/CulinaryCommandApp/Inventory/Services/InventoryTransactionService.cs +++ b/CulinaryCommandApp/Inventory/Services/InventoryTransactionService.cs @@ -5,12 +5,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using CulinaryCommand.Data; -using CulinaryCommand.Inventory.Entities; -using CulinaryCommand.Inventory.Services.Interfaces; +using CulinaryCommandApp.Inventory.Entities; +using CulinaryCommandApp.Inventory.Services.Interfaces; -namespace CulinaryCommand.Inventory.Services +namespace CulinaryCommandApp.Inventory.Services { public class InventoryTransactionService : IInventoryTransactionService { diff --git a/CulinaryCommandApp/Inventory/Services/UnitService.cs b/CulinaryCommandApp/Inventory/Services/UnitService.cs index 750125c..60fb631 100644 --- a/CulinaryCommandApp/Inventory/Services/UnitService.cs +++ b/CulinaryCommandApp/Inventory/Services/UnitService.cs @@ -3,11 +3,11 @@ using System.Threading; using System.Threading.Tasks; using CulinaryCommand.Data; -using CulinaryCommand.Inventory.Entities; -using CulinaryCommand.Inventory.Services.Interfaces; +using CulinaryCommandApp.Inventory.Entities; +using CulinaryCommandApp.Inventory.Services.Interfaces; using Microsoft.EntityFrameworkCore; -namespace CulinaryCommand.Inventory.Services +namespace CulinaryCommandApp.Inventory.Services { public class UnitService : IUnitService { @@ -67,5 +67,45 @@ public async Task> GetUnitsForIngredient(int ingredientId, Cancellati { return await GetAllAsync(cancellationToken); } + + + public async Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default) + { + return await _db.LocationUnits + .Where(lu => lu.LocationId == locationId) + .Include(lu => lu.Unit) + .Select(lu => lu.Unit) + .OrderBy(u => u.Name) + .ToListAsync(cancellationToken); + } + + + public async Task SetLocationUnitsAsync(int locationId, IEnumerable unitIds, CancellationToken cancellationToken = default) + { + var desired = unitIds.ToHashSet(); + + var existing = await _db.LocationUnits + .Where(lu => lu.LocationId == locationId) + .ToListAsync(cancellationToken); + + var existingIds = existing.Select(lu => lu.UnitId).ToHashSet(); + + // Remove units no longer in the list + var toRemove = existing.Where(lu => !desired.Contains(lu.UnitId)).ToList(); + _db.LocationUnits.RemoveRange(toRemove); + + // Add new units + foreach (var unitId in desired.Where(id => !existingIds.Contains(id))) + { + _db.LocationUnits.Add(new LocationUnit + { + LocationId = locationId, + UnitId = unitId, + AssignedAt = DateTime.UtcNow + }); + } + + await _db.SaveChangesAsync(cancellationToken); + } } } diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index eee80e5..1ff6292 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -5,12 +5,13 @@ using Microsoft.IdentityModel.Protocols.OpenIdConnect; using CulinaryCommand.Data; using CulinaryCommand.Services; -using CulinaryCommand.Inventory.Services; +using CulinaryCommandApp.Inventory.Services; using CulinaryCommand.PurchaseOrder.Services; using CulinaryCommand.Components; using CulinaryCommand.Inventory; -using CulinaryCommand.Inventory.Services.Interfaces; +using CulinaryCommandApp.Inventory.Services.Interfaces; using CulinaryCommandApp.AIDashboard.Services.Reporting; +using CulinaryCommandApp.Recipe.Services; using Google.GenAI; using System; using CulinaryCommand.Services.UserContextSpace; diff --git a/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs index 745baa4..3cc51d8 100644 --- a/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs +++ b/CulinaryCommandApp/PurchaseOrder/Entities/PurchaseOrderLine.cs @@ -1,5 +1,5 @@ // Represents a single ingredient on a purchase order -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; namespace CulinaryCommand.PurchaseOrder.Entities { diff --git a/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor b/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor index 27ecc16..ce2f025 100644 --- a/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor +++ b/CulinaryCommandApp/PurchaseOrder/Pages/Create.razor @@ -1,7 +1,7 @@ @page "/purchase-orders/create" @using CulinaryCommand.PurchaseOrder.DTOs @using CulinaryCommand.PurchaseOrder.Services -@using CulinaryCommand.Inventory.Entities +@using CulinaryCommandApp.Inventory.Entities @using Microsoft.EntityFrameworkCore @inject NavigationManager Nav @inject IPurchaseOrderService PurchaseOrderService diff --git a/CulinaryCommandApp/Recipe/Entities/Recipe.cs b/CulinaryCommandApp/Recipe/Entities/Recipe.cs new file mode 100644 index 0000000..d85ec62 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Entities/Recipe.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using CulinaryCommand.Data.Entities; + +namespace CulinaryCommandApp.Recipe.Entities +{ + public class Recipe + { + [Key] + public int RecipeId { get; set; } + + public int LocationId { get; set; } + public Location? Location { get; set; } + + [Required, MaxLength(128)] + public string Title { get; set; } = string.Empty; + + [MaxLength(128)] + public string Category { get; set; } = string.Empty; + + [MaxLength(128)] + public string RecipeType { get; set; } = string.Empty; + + [MaxLength(128)] + public string YieldUnit { get; set; } = string.Empty; + + public decimal? YieldAmount { get; set; } + + public decimal? CostPerYield { get; set; } + + public bool IsSubRecipe { get; set; } = false; + + public DateTime? CreatedAt { get; set; } + + // Navigation + public ICollection RecipeIngredients { get; set; } = new List(); + public ICollection Steps { get; set; } = new List(); + public ICollection SubRecipeUsages { get; set; } = new List(); + public ICollection UsedInRecipes { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Recipe/Entities/RecipeIngredient.cs b/CulinaryCommandApp/Recipe/Entities/RecipeIngredient.cs new file mode 100644 index 0000000..42a2434 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Entities/RecipeIngredient.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using CulinaryCommandApp.Inventory.Entities; + +namespace CulinaryCommandApp.Recipe.Entities +{ + public class RecipeIngredient + { + [Key] + public int RecipeIngredientId { get; set; } + + public int RecipeId { get; set; } + public Recipe? Recipe { get; set; } + + public int? IngredientId { get; set; } + public Ingredient? Ingredient { get; set; } + + public int? SubRecipeId { get; set; } + public Recipe? SubRecipe { get; set; } + + public int UnitId { get; set; } + public Unit? Unit { get; set; } + + public int SortOrder { get; set; } + + public decimal Quantity { get; set; } + + [MaxLength(256)] + public string? PrepNote { get; set; } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Recipe/Entities/RecipeStep.cs b/CulinaryCommandApp/Recipe/Entities/RecipeStep.cs new file mode 100644 index 0000000..be83d79 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Entities/RecipeStep.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace CulinaryCommandApp.Recipe.Entities +{ + public class RecipeStep + { + [Key] + public int StepId { get; set; } + + public int RecipeId { get; set; } + + public int StepNumber { get; set; } + + [MaxLength(2048)] + public string Instructions { get; set; } = string.Empty; + + // optional: estimated duration (ex: "5 minutes", "8-10 minutes") + [MaxLength(64)] + public string? Duration { get; set; } + + // optional: target temperature (ex: "375-400Β°F", "Internal: 145Β°F") + [MaxLength(64)] + public string? Temperature { get; set; } + + // optional: equipment or tools needed (ex: "Gas or charcoal grill", "Spatula") + [MaxLength(256)] + public string? Equipment { get; set; } + + // Navigation + public Recipe? Recipe { get; set; } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Recipe/Entities/RecipeSubRecipe.cs b/CulinaryCommandApp/Recipe/Entities/RecipeSubRecipe.cs new file mode 100644 index 0000000..5700506 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Entities/RecipeSubRecipe.cs @@ -0,0 +1,12 @@ +namespace CulinaryCommandApp.Recipe.Entities +{ + public class RecipeSubRecipe + { + // composite PK configured via Fluent API in AppDbContext + public int ParentRecipeId { get; set; } + public Recipe? ParentRecipe { get; set; } + + public int ChildRecipeId { get; set; } + public Recipe? ChildRecipe { get; set; } + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Services/RecipeService.cs b/CulinaryCommandApp/Recipe/Services/RecipeService.cs similarity index 79% rename from CulinaryCommandApp/Services/RecipeService.cs rename to CulinaryCommandApp/Recipe/Services/RecipeService.cs index 0f793c0..78839f5 100644 --- a/CulinaryCommandApp/Services/RecipeService.cs +++ b/CulinaryCommandApp/Recipe/Services/RecipeService.cs @@ -1,8 +1,8 @@ using CulinaryCommand.Data; -using CulinaryCommand.Data.Entities; +using Rec = CulinaryCommandApp.Recipe.Entities; using Microsoft.EntityFrameworkCore; -namespace CulinaryCommand.Services +namespace CulinaryCommandApp.Recipe.Services { public class RecipeService { @@ -13,13 +13,13 @@ public RecipeService(AppDbContext db) _db = db; } - public async Task> GetAllAsync() + public async Task> GetAllAsync() => await _db.Recipes .Include(r => r.RecipeIngredients) .Include(r => r.Steps) .ToListAsync(); - public async Task> GetAllByLocationIdAsync(int locationId) + public async Task> GetAllByLocationIdAsync(int locationId) { return await _db.Recipes .Where(r => r.LocationId == locationId) @@ -28,7 +28,7 @@ public async Task> GetAllByLocationIdAsync(int locationId) .ToListAsync(); } - public Task GetByIdAsync(int id) + public Task GetByIdAsync(int id) { return _db.Recipes .Include(r => r.RecipeIngredients) @@ -40,7 +40,7 @@ public async Task> GetAllByLocationIdAsync(int locationId) } - public async Task CreateAsync(Recipe recipe) + public async Task CreateAsync(Rec.Recipe recipe) { if (string.IsNullOrWhiteSpace(recipe.Category)) throw new Exception("Category is required."); @@ -49,7 +49,7 @@ public async Task CreateAsync(Recipe recipe) await _db.SaveChangesAsync(); } - public async Task UpdateAsync(Recipe recipe) + public async Task UpdateAsync(Rec.Recipe recipe) { _db.Recipes.Update(recipe); await _db.SaveChangesAsync(); diff --git a/CulinaryCommandUnitTests/Inventory/DTOs/CreateIngredientDTOTests.cs b/CulinaryCommandUnitTests/Inventory/DTOs/CreateIngredientDTOTests.cs index 2873c06..424e2e7 100644 --- a/CulinaryCommandUnitTests/Inventory/DTOs/CreateIngredientDTOTests.cs +++ b/CulinaryCommandUnitTests/Inventory/DTOs/CreateIngredientDTOTests.cs @@ -1,8 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using CulinaryCommand.Components.Pages.Recipes; -using CulinaryCommand.Inventory.DTOs; +using CulinaryCommandApp.Inventory.DTOs; using Xunit; diff --git a/CulinaryCommandUnitTests/Inventory/DTOs/InventoryCatalogDTOTests.cs b/CulinaryCommandUnitTests/Inventory/DTOs/InventoryCatalogDTOTests.cs index c9a1111..3c37305 100644 --- a/CulinaryCommandUnitTests/Inventory/DTOs/InventoryCatalogDTOTests.cs +++ b/CulinaryCommandUnitTests/Inventory/DTOs/InventoryCatalogDTOTests.cs @@ -1,4 +1,4 @@ -using CulinaryCommand.Inventory.DTOs; +using CulinaryCommandApp.Inventory.DTOs; using Xunit; namespace CulinaryCommandUnitTests.Inventory.DTOs diff --git a/CulinaryCommandUnitTests/Inventory/DTOs/InventoryItemDTOTests.cs b/CulinaryCommandUnitTests/Inventory/DTOs/InventoryItemDTOTests.cs index 7ae80b0..b17c987 100644 --- a/CulinaryCommandUnitTests/Inventory/DTOs/InventoryItemDTOTests.cs +++ b/CulinaryCommandUnitTests/Inventory/DTOs/InventoryItemDTOTests.cs @@ -1,5 +1,5 @@ using System; -using CulinaryCommand.Inventory.DTOs; +using CulinaryCommandApp.Inventory.DTOs; using Xunit; namespace CulinaryCommandUnitTests.Inventory.DTOs diff --git a/CulinaryCommandUnitTests/Inventory/Entities/IngredientTest.cs b/CulinaryCommandUnitTests/Inventory/Entities/IngredientTest.cs index 78fb6ce..65ef518 100644 --- a/CulinaryCommandUnitTests/Inventory/Entities/IngredientTest.cs +++ b/CulinaryCommandUnitTests/Inventory/Entities/IngredientTest.cs @@ -1,5 +1,5 @@ using System; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; using Xunit; namespace CulinaryCommandUnitTests.Inventory.Entities diff --git a/CulinaryCommandUnitTests/Inventory/Entities/InventoryTransactionTest.cs b/CulinaryCommandUnitTests/Inventory/Entities/InventoryTransactionTest.cs index 4301097..ca1ba00 100644 --- a/CulinaryCommandUnitTests/Inventory/Entities/InventoryTransactionTest.cs +++ b/CulinaryCommandUnitTests/Inventory/Entities/InventoryTransactionTest.cs @@ -1,5 +1,5 @@ using System; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; using Xunit; namespace CulinaryCommandUnitTests.Inventory.Entities diff --git a/CulinaryCommandUnitTests/Inventory/Entities/UnitTest.cs b/CulinaryCommandUnitTests/Inventory/Entities/UnitTest.cs index b397cb7..963f205 100644 --- a/CulinaryCommandUnitTests/Inventory/Entities/UnitTest.cs +++ b/CulinaryCommandUnitTests/Inventory/Entities/UnitTest.cs @@ -1,6 +1,6 @@ using System; using System.Linq; -using CulinaryCommand.Inventory.Entities; +using CulinaryCommandApp.Inventory.Entities; using Xunit; namespace CulinaryCommandUnitTests.Inventory.Entities From 13fd92f01a8e3116515f4617e71c3f399cb7fccd Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Fri, 27 Feb 2026 14:13:57 -0600 Subject: [PATCH 02/15] feat: refactor Recipe entity + Recipe/Unit database migrations --- CulinaryCommandApp/Data/AppDbContext.cs | 2 +- CulinaryCommandApp/Data/Entities/Location.cs | 4 +- CulinaryCommandApp/Data/Entities/Tasks.cs | 4 +- .../Inventory/Services/IngredientService.cs | 11 + .../Services/Interfaces/IIngredientService.cs | 3 + ...2_RecipeAndLocationUnitSupport.Designer.cs | 1121 +++++++++++++++++ ...0227192322_RecipeAndLocationUnitSupport.cs | 346 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 801 ++++++------ .../Recipe/Pages/RecipeList.razor | 13 + .../Recipe/Pages/RecipeList.razor.css | 391 ++++++ .../Recipe/Pages/_Imports.razor | 8 + 11 files changed, 2335 insertions(+), 369 deletions(-) create mode 100644 CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.Designer.cs create mode 100644 CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.cs create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeList.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeList.razor.css create mode 100644 CulinaryCommandApp/Recipe/Pages/_Imports.razor diff --git a/CulinaryCommandApp/Data/AppDbContext.cs b/CulinaryCommandApp/Data/AppDbContext.cs index 1de3a0e..90903d1 100644 --- a/CulinaryCommandApp/Data/AppDbContext.cs +++ b/CulinaryCommandApp/Data/AppDbContext.cs @@ -120,7 +120,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Recipe belongs to a Location modelBuilder.Entity() .HasOne(r => r.Location) - .WithMany() + .WithMany(l => l.Recipes) .HasForeignKey(r => r.LocationId) .OnDelete(DeleteBehavior.Cascade); diff --git a/CulinaryCommandApp/Data/Entities/Location.cs b/CulinaryCommandApp/Data/Entities/Location.cs index 96b230d..2025d93 100644 --- a/CulinaryCommandApp/Data/Entities/Location.cs +++ b/CulinaryCommandApp/Data/Entities/Location.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using CulinaryCommandApp.Inventory.Entities; -using CulinaryCommandApp.Recipe.Entities; +using Rec = CulinaryCommandApp.Recipe.Entities; namespace CulinaryCommand.Data.Entities { @@ -33,7 +33,7 @@ public class Location public int CompanyId { get; set; } public Company Company { get; set; } - public ICollection Recipes { get; set; } = new List(); + public ICollection Recipes { get; set; } = new List(); // join table combining employees and locations [JsonIgnore] diff --git a/CulinaryCommandApp/Data/Entities/Tasks.cs b/CulinaryCommandApp/Data/Entities/Tasks.cs index 0c18781..af8586d 100644 --- a/CulinaryCommandApp/Data/Entities/Tasks.cs +++ b/CulinaryCommandApp/Data/Entities/Tasks.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using CulinaryCommand.Data.Enums; -using CulinaryCommandApp.Recipe.Entities; +using Rec = CulinaryCommandApp.Recipe.Entities; using InvIngredient = CulinaryCommandApp.Inventory.Entities.Ingredient; namespace CulinaryCommand.Data.Entities @@ -53,7 +53,7 @@ public class Tasks // Link to recipe if this is a prep task public int? RecipeId { get; set; } - public Recipe? Recipe { get; set; } + public Rec.Recipe? Recipe { get; set; } // Link to ingredient (Inventory.Entities.Ingredient) public int? IngredientId { get; set; } diff --git a/CulinaryCommandApp/Inventory/Services/IngredientService.cs b/CulinaryCommandApp/Inventory/Services/IngredientService.cs index 5453502..49e20c5 100644 --- a/CulinaryCommandApp/Inventory/Services/IngredientService.cs +++ b/CulinaryCommandApp/Inventory/Services/IngredientService.cs @@ -27,6 +27,17 @@ public async Task> GetCategoriesAsync(CancellationToken cancellatio .ToListAsync(cancellationToken); } + public async Task> GetIngredientsByLocationAsync(int locationId, CancellationToken cancellationToken = default) + { + return await _db.Ingredients + .AsNoTracking() + .Include(i => i.Unit) + .Where(i => i.LocationId == locationId) + .OrderBy(i => i.Category) + .ThenBy(i => i.Name) + .ToListAsync(cancellationToken); + } + public async Task> GetByCategoryAsync(string category, CancellationToken cancellationToken = default) { return await _db.Ingredients diff --git a/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs b/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs index 120e633..d54747b 100644 --- a/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs +++ b/CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs @@ -10,6 +10,9 @@ public interface IIngredientService // Retrieves all ingredient categories. Task> GetCategoriesAsync(CancellationToken cancellationToken = default); + // returns all ingredients belonging to a specific restaurant location. specifically references ingredients populated in /inventory-catalog + Task> GetIngredientsByLocationAsync(int locationId, CancellationToken cancellationToken = default); + // Retrieves ingredients that belong to the provided category. Task> GetByCategoryAsync(string category, CancellationToken cancellationToken = default); diff --git a/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.Designer.cs b/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.Designer.cs new file mode 100644 index 0000000..12f212a --- /dev/null +++ b/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.Designer.cs @@ -0,0 +1,1121 @@ +ο»Ώ// +using System; +using CulinaryCommand.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CulinaryCommand.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260227192322_RecipeAndLocationUnitSupport")] + partial class RecipeAndLocationUnitSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("City") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CompanyCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("LLCName") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Phone") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("State") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TaxId") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ZipCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.HasKey("Id"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("City") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("MarginEdgeKey") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ZipCode") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.HasKey("UserId", "LocationId"); + + b.HasIndex("LocationId"); + + b.ToTable("ManagerLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Assigner") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DueDate") + .HasColumnType("datetime(6)"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Par") + .HasColumnType("int"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("Station") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("LocationId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UserId"); + + b.ToTable("Tasks"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit(1)"); + + b.Property("InviteToken") + .HasColumnType("longtext"); + + b.Property("InviteTokenExpires") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("bit(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Password") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Phone") + .HasColumnType("longtext"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("StationsWorked") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.UserLocation", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Role") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.HasKey("UserId", "LocationId"); + + b.HasIndex("LocationId"); + + b.ToTable("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("datetime(6)"); + + b.Property("IsLocationLocked") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorContact") + .HasColumnType("longtext"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("PurchaseOrderId") + .HasColumnType("int"); + + b.Property("QuantityOrdered") + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)"); + + b.Property("QuantityReceived") + .ValueGeneratedOnAdd() + .HasPrecision(18, 3) + .HasColumnType("decimal(18,3)") + .HasDefaultValue(0m); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("UnitId"); + + b.ToTable("PurchaseOrderLines"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.LocationVendor", b => + { + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("AssignedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("LocationId", "VendorId"); + + b.HasIndex("VendorId"); + + b.ToTable("LocationVendors"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ContactName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("bit(1)"); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Website") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Ingredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("IngredientId"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.Property("ReorderLevel") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .HasColumnType("longtext"); + + b.Property("StockQuantity") + .HasColumnType("decimal(18, 4)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("UnitId"); + + b.HasIndex("VendorId"); + + b.ToTable("Ingredients", (string)null); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.InventoryTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StockChange") + .HasColumnType("decimal(65,30)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("UnitId"); + + b.ToTable("InventoryTransactions"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.LocationUnit", b => + { + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("AssignedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("LocationId", "UnitId"); + + b.HasIndex("UnitId"); + + b.ToTable("LocationUnits"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Unit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversionFactor") + .HasColumnType("decimal(65,30)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Units"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => + { + b.Property("RecipeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CostPerYield") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSubRecipe") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("RecipeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("YieldAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("YieldUnit") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("RecipeId"); + + b.HasIndex("LocationId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeIngredient", b => + { + b.Property("RecipeIngredientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeIngredientId")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("PrepNote") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SubRecipeId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("RecipeIngredientId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("SubRecipeId"); + + b.HasIndex("UnitId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeStep", b => + { + b.Property("StepId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StepId")); + + b.Property("Duration") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Equipment") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Instructions") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("varchar(2048)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("StepNumber") + .HasColumnType("int"); + + b.Property("Temperature") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("StepId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeSteps"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeSubRecipe", b => + { + b.Property("ParentRecipeId") + .HasColumnType("int"); + + b.Property("ChildRecipeId") + .HasColumnType("int"); + + b.HasKey("ParentRecipeId", "ChildRecipeId"); + + b.HasIndex("ChildRecipeId"); + + b.ToTable("RecipeSubRecipes"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Locations") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("ManagerLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany("ManagerLocations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId"); + + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Ingredient"); + + b.Navigation("Location"); + + b.Navigation("Recipe"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Employees") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.UserLocation", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("UserLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany("UserLocations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.LocationVendor", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("LocationVendors") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Vendor.Entities.Vendor", "Vendor") + .WithMany("LocationVendors") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Ingredient", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany("Ingredients") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Vendor.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Location"); + + b.Navigation("Unit"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.InventoryTransaction", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany("InventoryTransaction") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.LocationUnit", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("LocationUnits") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany("LocationUnits") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("Recipes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeIngredient", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") + .WithMany("RecipeIngredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "SubRecipe") + .WithMany() + .HasForeignKey("SubRecipeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Recipe"); + + b.Navigation("SubRecipe"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeStep", b => + { + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") + .WithMany("Steps") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeSubRecipe", b => + { + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "ChildRecipe") + .WithMany("UsedInRecipes") + .HasForeignKey("ChildRecipeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "ParentRecipe") + .WithMany("SubRecipeUsages") + .HasForeignKey("ParentRecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildRecipe"); + + b.Navigation("ParentRecipe"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => + { + b.Navigation("Employees"); + + b.Navigation("Locations"); + + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.Navigation("LocationUnits"); + + b.Navigation("LocationVendors"); + + b.Navigation("ManagerLocations"); + + b.Navigation("Recipes"); + + b.Navigation("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + { + b.Navigation("ManagerLocations"); + + b.Navigation("UserLocations"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + { + b.Navigation("LocationVendors"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Unit", b => + { + b.Navigation("Ingredients"); + + b.Navigation("InventoryTransaction"); + + b.Navigation("LocationUnits"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => + { + b.Navigation("RecipeIngredients"); + + b.Navigation("Steps"); + + b.Navigation("SubRecipeUsages"); + + b.Navigation("UsedInRecipes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.cs b/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.cs new file mode 100644 index 0000000..1225678 --- /dev/null +++ b/CulinaryCommandApp/Migrations/20260227192322_RecipeAndLocationUnitSupport.cs @@ -0,0 +1,346 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CulinaryCommand.Migrations +{ + /// + public partial class RecipeAndLocationUnitSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Ingredients_IngredientId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_MeasurementUnits_MeasurementUnitUnitId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Units_UnitId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_Tasks_Recipes_RecipeId", + table: "Tasks"); + + migrationBuilder.DropTable( + name: "MeasurementUnits"); + + migrationBuilder.RenameColumn( + name: "MeasurementUnitUnitId", + table: "RecipeIngredients", + newName: "SubRecipeId"); + + migrationBuilder.RenameIndex( + name: "IX_RecipeIngredients_MeasurementUnitUnitId", + table: "RecipeIngredients", + newName: "IX_RecipeIngredients_SubRecipeId"); + + migrationBuilder.UpdateData( + table: "RecipeSteps", + keyColumn: "Instructions", + keyValue: null, + column: "Instructions", + value: ""); + + migrationBuilder.AlterColumn( + name: "Instructions", + table: "RecipeSteps", + type: "varchar(2048)", + maxLength: 2048, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(256)", + oldMaxLength: 256, + oldNullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Duration", + table: "RecipeSteps", + type: "varchar(64)", + maxLength: 64, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Equipment", + table: "RecipeSteps", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Temperature", + table: "RecipeSteps", + type: "varchar(64)", + maxLength: 64, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Recipes", + type: "datetime(6)", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime(6)"); + + migrationBuilder.AddColumn( + name: "CostPerYield", + table: "Recipes", + type: "decimal(65,30)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSubRecipe", + table: "Recipes", + type: "bit(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AlterColumn( + name: "IngredientId", + table: "RecipeIngredients", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.CreateTable( + name: "LocationUnits", + columns: table => new + { + LocationId = table.Column(type: "int", nullable: false), + UnitId = table.Column(type: "int", nullable: false), + AssignedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LocationUnits", x => new { x.LocationId, x.UnitId }); + table.ForeignKey( + name: "FK_LocationUnits_Locations_LocationId", + column: x => x.LocationId, + principalTable: "Locations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LocationUnits_Units_UnitId", + column: x => x.UnitId, + principalTable: "Units", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "RecipeSubRecipes", + columns: table => new + { + ParentRecipeId = table.Column(type: "int", nullable: false), + ChildRecipeId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RecipeSubRecipes", x => new { x.ParentRecipeId, x.ChildRecipeId }); + table.ForeignKey( + name: "FK_RecipeSubRecipes_Recipes_ChildRecipeId", + column: x => x.ChildRecipeId, + principalTable: "Recipes", + principalColumn: "RecipeId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_RecipeSubRecipes_Recipes_ParentRecipeId", + column: x => x.ParentRecipeId, + principalTable: "Recipes", + principalColumn: "RecipeId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_LocationUnits_UnitId", + table: "LocationUnits", + column: "UnitId"); + + migrationBuilder.CreateIndex( + name: "IX_RecipeSubRecipes_ChildRecipeId", + table: "RecipeSubRecipes", + column: "ChildRecipeId"); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Ingredients_IngredientId", + table: "RecipeIngredients", + column: "IngredientId", + principalTable: "Ingredients", + principalColumn: "IngredientId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Recipes_SubRecipeId", + table: "RecipeIngredients", + column: "SubRecipeId", + principalTable: "Recipes", + principalColumn: "RecipeId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Units_UnitId", + table: "RecipeIngredients", + column: "UnitId", + principalTable: "Units", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Tasks_Recipes_RecipeId", + table: "Tasks", + column: "RecipeId", + principalTable: "Recipes", + principalColumn: "RecipeId", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Ingredients_IngredientId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Recipes_SubRecipeId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Units_UnitId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_Tasks_Recipes_RecipeId", + table: "Tasks"); + + migrationBuilder.DropTable( + name: "LocationUnits"); + + migrationBuilder.DropTable( + name: "RecipeSubRecipes"); + + migrationBuilder.DropColumn( + name: "Duration", + table: "RecipeSteps"); + + migrationBuilder.DropColumn( + name: "Equipment", + table: "RecipeSteps"); + + migrationBuilder.DropColumn( + name: "Temperature", + table: "RecipeSteps"); + + migrationBuilder.DropColumn( + name: "CostPerYield", + table: "Recipes"); + + migrationBuilder.DropColumn( + name: "IsSubRecipe", + table: "Recipes"); + + migrationBuilder.RenameColumn( + name: "SubRecipeId", + table: "RecipeIngredients", + newName: "MeasurementUnitUnitId"); + + migrationBuilder.RenameIndex( + name: "IX_RecipeIngredients_SubRecipeId", + table: "RecipeIngredients", + newName: "IX_RecipeIngredients_MeasurementUnitUnitId"); + + migrationBuilder.AlterColumn( + name: "Instructions", + table: "RecipeSteps", + type: "varchar(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(2048)", + oldMaxLength: 2048) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Recipes", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "datetime(6)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "IngredientId", + table: "RecipeIngredients", + type: "int", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "MeasurementUnits", + columns: table => new + { + UnitId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Abbreviation = table.Column(type: "varchar(32)", maxLength: 32, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_MeasurementUnits", x => x.UnitId); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Ingredients_IngredientId", + table: "RecipeIngredients", + column: "IngredientId", + principalTable: "Ingredients", + principalColumn: "IngredientId", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_MeasurementUnits_MeasurementUnitUnitId", + table: "RecipeIngredients", + column: "MeasurementUnitUnitId", + principalTable: "MeasurementUnits", + principalColumn: "UnitId"); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Units_UnitId", + table: "RecipeIngredients", + column: "UnitId", + principalTable: "Units", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Tasks_Recipes_RecipeId", + table: "Tasks", + column: "RecipeId", + principalTable: "Recipes", + principalColumn: "RecipeId"); + } + } +} diff --git a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs index 26a716b..0be4570 100644 --- a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs +++ b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs @@ -145,141 +145,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ManagerLocations"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.MeasurementUnit", b => - { - b.Property("UnitId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UnitId")); - - b.Property("Abbreviation") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("varchar(32)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - - b.HasKey("UnitId"); - - b.ToTable("MeasurementUnits"); - }); - - modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => - { - b.Property("RecipeId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeId")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)"); - - b.Property("LocationId") - .HasColumnType("int"); - - b.Property("RecipeType") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - - b.Property("YieldAmount") - .HasColumnType("decimal(65,30)"); - - b.Property("YieldUnit") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - - b.HasKey("RecipeId"); - - b.HasIndex("LocationId"); - - b.ToTable("Recipes"); - }); - - modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeIngredient", b => - { - b.Property("RecipeIngredientId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeIngredientId")); - - b.Property("IngredientId") - .HasColumnType("int"); - - b.Property("MeasurementUnitUnitId") - .HasColumnType("int"); - - b.Property("PrepNote") - .HasMaxLength(256) - .HasColumnType("varchar(256)"); - - b.Property("Quantity") - .HasColumnType("decimal(65,30)"); - - b.Property("RecipeId") - .HasColumnType("int"); - - b.Property("SortOrder") - .HasColumnType("int"); - - b.Property("UnitId") - .HasColumnType("int"); - - b.HasKey("RecipeIngredientId"); - - b.HasIndex("IngredientId"); - - b.HasIndex("MeasurementUnitUnitId"); - - b.HasIndex("RecipeId"); - - b.HasIndex("UnitId"); - - b.ToTable("RecipeIngredients"); - }); - - modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeStep", b => - { - b.Property("StepId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StepId")); - - b.Property("Instructions") - .HasMaxLength(256) - .HasColumnType("varchar(256)"); - - b.Property("RecipeId") - .HasColumnType("int"); - - b.Property("StepNumber") - .HasColumnType("int"); - - b.HasKey("StepId"); - - b.HasIndex("RecipeId"); - - b.ToTable("RecipeSteps"); - }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => { b.Property("Id") @@ -442,123 +307,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserLocations"); }); - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Ingredient", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("IngredientId"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("varchar(100)"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)"); - - b.Property("LocationId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("varchar(200)"); - - b.Property("Notes") - .HasColumnType("longtext"); - - b.Property("Price") - .HasColumnType("decimal(65,30)"); - - b.Property("ReorderLevel") - .HasColumnType("decimal(65,30)"); - - b.Property("Sku") - .HasColumnType("longtext"); - - b.Property("StockQuantity") - .HasColumnType("decimal(18, 4)"); - - b.Property("UnitId") - .HasColumnType("int"); - - b.Property("UpdatedAt") - .HasColumnType("datetime(6)"); - - b.Property("VendorId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("LocationId"); - - b.HasIndex("UnitId"); - - b.HasIndex("VendorId"); - - b.ToTable("Ingredients", (string)null); - }); - - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.InventoryTransaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)"); - - b.Property("IngredientId") - .HasColumnType("int"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("StockChange") - .HasColumnType("decimal(65,30)"); - - b.Property("UnitId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("IngredientId"); - - b.HasIndex("UnitId"); - - b.ToTable("InventoryTransactions"); - }); - - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Unit", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - - b.Property("Abbreviation") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("ConversionFactor") - .HasColumnType("decimal(65,30)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("longtext"); - - b.HasKey("Id"); - - b.ToTable("Units"); - }); - modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => { b.Property("Id") @@ -730,92 +478,320 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Vendors"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Ingredient", b => { - b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") - .WithMany("Locations") - .HasForeignKey("CompanyId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("IngredientId"); - b.Navigation("Company"); - }); + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => - { - b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") - .WithMany("ManagerLocations") - .HasForeignKey("LocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); - b.HasOne("CulinaryCommand.Data.Entities.User", "User") - .WithMany("ManagerLocations") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); - b.Navigation("Location"); + b.Property("LocationId") + .HasColumnType("int"); - b.Navigation("User"); + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.Property("ReorderLevel") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .HasColumnType("longtext"); + + b.Property("StockQuantity") + .HasColumnType("decimal(18, 4)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("UnitId"); + + b.HasIndex("VendorId"); + + b.ToTable("Ingredients", (string)null); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.InventoryTransaction", b => { - b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") - .WithMany("Recipes") - .HasForeignKey("LocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); - b.Navigation("Location"); + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StockChange") + .HasColumnType("decimal(65,30)"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("UnitId"); + + b.ToTable("InventoryTransactions"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeIngredient", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.LocationUnit", b => { - b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") - .WithMany() - .HasForeignKey("IngredientId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("LocationId") + .HasColumnType("int"); - b.HasOne("CulinaryCommand.Data.Entities.MeasurementUnit", null) - .WithMany("RecipeIngredients") - .HasForeignKey("MeasurementUnitUnitId"); + b.Property("UnitId") + .HasColumnType("int"); - b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") - .WithMany("RecipeIngredients") - .HasForeignKey("RecipeId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Property("AssignedAt") + .HasColumnType("datetime(6)"); - b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") - .WithMany() - .HasForeignKey("UnitId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasKey("LocationId", "UnitId"); - b.Navigation("Ingredient"); + b.HasIndex("UnitId"); - b.Navigation("Recipe"); + b.ToTable("LocationUnits"); + }); - b.Navigation("Unit"); + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Unit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ConversionFactor") + .HasColumnType("decimal(65,30)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Units"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.RecipeStep", b => + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => { - b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") - .WithMany("Steps") - .HasForeignKey("RecipeId") + b.Property("RecipeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CostPerYield") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSubRecipe") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("RecipeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("YieldAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("YieldUnit") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("RecipeId"); + + b.HasIndex("LocationId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeIngredient", b => + { + b.Property("RecipeIngredientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeIngredientId")); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("PrepNote") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SubRecipeId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("RecipeIngredientId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("SubRecipeId"); + + b.HasIndex("UnitId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeStep", b => + { + b.Property("StepId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StepId")); + + b.Property("Duration") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Equipment") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Instructions") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("varchar(2048)"); + + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("StepNumber") + .HasColumnType("int"); + + b.Property("Temperature") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("StepId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeSteps"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeSubRecipe", b => + { + b.Property("ParentRecipeId") + .HasColumnType("int"); + + b.Property("ChildRecipeId") + .HasColumnType("int"); + + b.HasKey("ParentRecipeId", "ChildRecipeId"); + + b.HasIndex("ChildRecipeId"); + + b.ToTable("RecipeSubRecipes"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Locations") + .HasForeignKey("CompanyId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Recipe"); + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.ManagerLocation", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("ManagerLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany("ManagerLocations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("User"); }); modelBuilder.Entity("CulinaryCommand.Data.Entities.Tasks", b => { - b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") .WithMany() .HasForeignKey("IngredientId"); @@ -825,9 +801,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("CulinaryCommand.Data.Entities.Recipe", "Recipe") + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") .WithMany() - .HasForeignKey("RecipeId"); + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); b.HasOne("CulinaryCommand.Data.Entities.User", "User") .WithMany() @@ -871,7 +848,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Ingredient", b => + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.LocationVendor", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("LocationVendors") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Vendor.Entities.Vendor", "Vendor") + .WithMany("LocationVendors") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Ingredient", b => { b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") .WithMany() @@ -879,7 +924,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") .WithMany("Ingredients") .HasForeignKey("UnitId") .OnDelete(DeleteBehavior.Cascade) @@ -897,15 +942,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Vendor"); }); - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.InventoryTransaction", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.InventoryTransaction", b => { - b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") .WithMany() .HasForeignKey("IngredientId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") .WithMany("InventoryTransaction") .HasForeignKey("UnitId") .OnDelete(DeleteBehavior.Cascade) @@ -916,32 +961,55 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Unit"); }); - modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.LocationUnit", b => { b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") - .WithMany() + .WithMany("LocationUnits") .HasForeignKey("LocationId") - .OnDelete(DeleteBehavior.Restrict) + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") + .WithMany("LocationUnits") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("Location"); + + b.Navigation("Unit"); }); - modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrderLine", b => + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => + { + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany("Recipes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeIngredient", b => { - b.HasOne("CulinaryCommand.Inventory.Entities.Ingredient", "Ingredient") + b.HasOne("CulinaryCommandApp.Inventory.Entities.Ingredient", "Ingredient") .WithMany() .HasForeignKey("IngredientId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.Restrict); - b.HasOne("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany("Lines") - .HasForeignKey("PurchaseOrderId") - .OnDelete(DeleteBehavior.Restrict) + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") + .WithMany("RecipeIngredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("CulinaryCommand.Inventory.Entities.Unit", "Unit") + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "SubRecipe") + .WithMany() + .HasForeignKey("SubRecipeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("CulinaryCommandApp.Inventory.Entities.Unit", "Unit") .WithMany() .HasForeignKey("UnitId") .OnDelete(DeleteBehavior.Restrict) @@ -949,39 +1017,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Ingredient"); - b.Navigation("PurchaseOrder"); + b.Navigation("Recipe"); + + b.Navigation("SubRecipe"); b.Navigation("Unit"); }); - modelBuilder.Entity("CulinaryCommand.Vendor.Entities.LocationVendor", b => + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeStep", b => { - b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") - .WithMany("LocationVendors") - .HasForeignKey("LocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("CulinaryCommand.Vendor.Entities.Vendor", "Vendor") - .WithMany("LocationVendors") - .HasForeignKey("VendorId") + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "Recipe") + .WithMany("Steps") + .HasForeignKey("RecipeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Location"); - - b.Navigation("Vendor"); + b.Navigation("Recipe"); }); - modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.RecipeSubRecipe", b => { - b.HasOne("CulinaryCommand.Data.Entities.Company", "Company") - .WithMany("Vendors") - .HasForeignKey("CompanyId") + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "ChildRecipe") + .WithMany("UsedInRecipes") + .HasForeignKey("ChildRecipeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CulinaryCommandApp.Recipe.Entities.Recipe", "ParentRecipe") + .WithMany("SubRecipeUsages") + .HasForeignKey("ParentRecipeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Company"); + b.Navigation("ChildRecipe"); + + b.Navigation("ParentRecipe"); }); modelBuilder.Entity("CulinaryCommand.Data.Entities.Company", b => @@ -995,6 +1065,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("CulinaryCommand.Data.Entities.Location", b => { + b.Navigation("LocationUnits"); + b.Navigation("LocationVendors"); b.Navigation("ManagerLocations"); @@ -1004,40 +1076,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserLocations"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.MeasurementUnit", b => + modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => { - b.Navigation("RecipeIngredients"); + b.Navigation("ManagerLocations"); + + b.Navigation("UserLocations"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.Recipe", b => + modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => { - b.Navigation("RecipeIngredients"); - - b.Navigation("Steps"); + b.Navigation("Lines"); }); - modelBuilder.Entity("CulinaryCommand.Data.Entities.User", b => + modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => { - b.Navigation("ManagerLocations"); - - b.Navigation("UserLocations"); + b.Navigation("LocationVendors"); }); - modelBuilder.Entity("CulinaryCommand.Inventory.Entities.Unit", b => + modelBuilder.Entity("CulinaryCommandApp.Inventory.Entities.Unit", b => { b.Navigation("Ingredients"); b.Navigation("InventoryTransaction"); - }); - modelBuilder.Entity("CulinaryCommand.PurchaseOrder.Entities.PurchaseOrder", b => - { - b.Navigation("Lines"); + b.Navigation("LocationUnits"); }); - modelBuilder.Entity("CulinaryCommand.Vendor.Entities.Vendor", b => + modelBuilder.Entity("CulinaryCommandApp.Recipe.Entities.Recipe", b => { - b.Navigation("LocationVendors"); + b.Navigation("RecipeIngredients"); + + b.Navigation("Steps"); + + b.Navigation("SubRecipeUsages"); + + b.Navigation("UsedInRecipes"); }); #pragma warning restore 612, 618 } diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeList.razor b/CulinaryCommandApp/Recipe/Pages/RecipeList.razor new file mode 100644 index 0000000..307bac7 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/RecipeList.razor @@ -0,0 +1,13 @@ +@page "/recipes" +@rendermode InteractiveServer + +Recipes + +
+
+
+

Recipes

+

Manage your location's recipes and prep items

+
+
+
diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeList.razor.css b/CulinaryCommandApp/Recipe/Pages/RecipeList.razor.css new file mode 100644 index 0000000..a9d2787 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/RecipeList.razor.css @@ -0,0 +1,391 @@ +:root { + --ink: #1f2a37; + --muted: #6b7280; + --border: #e5e7eb; + --bg: #f6f7f9; + --card: #ffffff; + --green: #0a8f3c; + --green-dark: #087136; + --pill: #f0f2f5; + --shadow: 0 10px 30px rgba(31, 42, 55, 0.08); +} + +/* ── Layout ──────────────────────────────────────────────────────────────── */ + +.recipes-container { + max-width: 1280px; + margin: 0 auto; + padding: 24px 24px 40px; + background: var(--bg); + min-height: 100vh; + font-family: "Source Sans 3", "Segoe UI", sans-serif; + color: var(--ink); +} + +.recipes-container button, +.recipes-container .btn { + font-family: "Source Sans 3", "Segoe UI", sans-serif; + letter-spacing: 0.01em; + transition: all 0.2s ease; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ + +.recipes-header { + background: var(--card); + border-radius: 16px; + padding: 22px 26px; + box-shadow: var(--shadow); + border: 1px solid var(--border); + margin-bottom: 18px; +} + +.header-text h1 { + font-family: "Space Grotesk", "Source Sans 3", sans-serif; + font-size: 1.6rem; + margin: 0 0 4px; +} + +.header-text p { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +/* ── Toolbar ─────────────────────────────────────────────────────────────── */ + +.recipes-toolbar { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + background: var(--card); + border-radius: 16px; + padding: 16px 18px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + margin-bottom: 14px; +} + +.search-bar { + display: flex; + align-items: center; + gap: 10px; + flex: 2 1 400px; + background: #f9fafb; + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 14px; + min-width: 280px; +} + +.search-bar i { + color: var(--muted); +} + +.search-bar input { + border: none; + outline: none; + background: transparent; + width: 100%; + font-size: 0.95rem; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.icon-btn { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--green); + background: var(--green); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ink); + transition: all 0.2s ease; + font-weight: 600; +} + +.icon-btn:hover { + background: var(--green-dark); + border-color: var(--green-dark); + box-shadow: 0 6px 12px rgba(10, 143, 60, 0.25); +} + +.select-wrap .form-select { + border-radius: 10px; + border: 1px solid var(--border); + padding: 8px 30px 8px 12px; + font-size: 0.9rem; + background-color: #ffffff; +} + +.btn-add { + background: var(--green); + color: var(--ink); + border: none; + padding: 9px 14px; + border-radius: 10px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background 0.2s ease; + box-shadow: 0 8px 16px rgba(10, 143, 60, 0.18); +} + +.btn-add:hover { + background: var(--green-dark); + transform: translateY(-1px); +} + +/* ── Tabs ────────────────────────────────────────────────────────────────── */ + +.tab-row { + display: flex; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.tab-btn { + border: 1px solid var(--green); + background: #ffffff; + padding: 8px 14px; + border-radius: 999px; + font-weight: 600; + font-size: 0.9rem; + color: var(--ink); + transition: all 0.2s ease; +} + +.tab-btn:hover { + background: var(--green); + color: #ffffff; + border-color: var(--green); + box-shadow: 0 4px 12px rgba(10, 143, 60, 0.2); +} + +.tab-btn.active { + background: var(--green); + color: var(--ink); + border-color: var(--green); + box-shadow: 0 4px 12px rgba(10, 143, 60, 0.25); +} + +/* ── Table ───────────────────────────────────────────────────────────────── */ + +.recipes-table-container { + background: var(--card); + border-radius: 16px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + overflow: hidden; +} + +.recipes-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.recipes-table thead { + background: var(--green); + color: var(--ink); +} + +.recipes-table thead th { + padding: 14px 12px; + text-align: left; + font-weight: 600; + font-size: 0.82rem; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; + white-space: nowrap; +} + +.recipes-table thead th i { + margin-left: 6px; + opacity: 0.8; +} + +.recipes-table tbody tr { + border-bottom: 1px solid var(--border); + background: #ffffff; +} + +.recipes-table tbody tr:hover { + background: #f9fafb; +} + +.recipes-table tbody td { + padding: 14px 12px; + vertical-align: middle; +} + +.actions-col { + width: 110px; +} + +/* ── Cells ───────────────────────────────────────────────────────────────── */ + +.name-cell .item-name { + font-weight: 600; + color: var(--ink); +} + +.category-pill { + display: inline-flex; + align-items: center; + padding: 5px 11px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + background: var(--pill); + color: var(--ink); +} + +.cost-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 700; + background: #dcfce7; + color: #166534; +} + +.sub-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 8px; + font-size: 0.78rem; + font-weight: 600; + background: #eff6ff; + color: #1d4ed8; +} + +/* ── Action Buttons ──────────────────────────────────────────────────────── */ + +.action-buttons { + display: flex; + gap: 6px; +} + +.btn-action { + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid var(--border); + background: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.85rem; + transition: all 0.15s ease; +} + +.btn-action:hover { + background: #f3f4f6; + color: var(--ink); + border-color: #d1d5db; +} + +.btn-action.delete:hover { + background: #fef2f2; + color: #dc2626; + border-color: #fca5a5; +} + +/* ── Empty State ─────────────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 56px 24px; +} + +.empty-icon { + font-size: 2.5rem; + color: var(--muted); + display: block; + margin-bottom: 12px; +} + +.empty-state p { + color: var(--muted); + margin-bottom: 16px; +} + +/* ── Footer / Pagination ─────────────────────────────────────────────────── */ + +.table-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-top: 1px solid var(--border); + flex-wrap: wrap; + gap: 10px; +} + +.footer-count { + font-size: 0.88rem; + color: var(--muted); +} + +.pagination { + display: flex; + align-items: center; + gap: 6px; +} + +.page-nav, +.page-btn { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--border); + background: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + color: var(--ink); + transition: all 0.15s ease; +} + +.page-nav:hover, +.page-btn:hover { + background: var(--green); + border-color: var(--green); + color: #ffffff; +} + +.page-btn.active { + background: var(--green); + border-color: var(--green); + color: #ffffff; + font-weight: 700; +} + +.page-nav:disabled { + opacity: 0.35; + pointer-events: none; +} + +.page-ellipsis { + color: var(--muted); + font-size: 0.85rem; +} diff --git a/CulinaryCommandApp/Recipe/Pages/_Imports.razor b/CulinaryCommandApp/Recipe/Pages/_Imports.razor new file mode 100644 index 0000000..63ba00c --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/_Imports.razor @@ -0,0 +1,8 @@ +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.JSInterop +@using CulinaryCommandApp.Recipe.Entities +@using CulinaryCommandApp.Recipe.Services +@using CulinaryCommand.Services From 8f400fa675f371508e22c6168bf5e2a70835654c Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Fri, 27 Feb 2026 15:00:30 -0600 Subject: [PATCH 03/15] feat: create recipe ingredient line logic --- .../Recipe/Pages/IngredientLineRow.razor | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor diff --git a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor new file mode 100644 index 0000000..7a23a96 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor @@ -0,0 +1,75 @@ +@using CulinaryCommand.Data.Entities + + + +
+ +
+ + +
+ + @if (!IsSubRecipeMode) { + + } + +
+ + +@code { + [Parameter, EditorRequired] public RecipeIngredient Line { get; set; } = default!; + [Parameter, EditorRequired] public int LocationId { get; set; } + [Parameter, EditorRequired] public List SubRecipes { get; set; } = new(); + [Parameter] public EventCallback OnRemove { get; set; } + + // true when this line refers to a sub-recipe (has SubRecipeId) + private bool IsSubRecipeMode => Line.SubRecipeId.HasValue; + private List availableIngredients = new(); + private List ingredientCategories = new(); + + + private IEnumerable IngredientsByCategory(string category) => availableIngredients + .Where(i => i.Category == category) + .OrderBy(i => i.Name); + + // Switch mode - when enabling sub-recipe mode clear IngredientId, and vice-versa + // to keep the line in a consistent state with only one reference active. + private void SetMode(bool subRecipeMode) { + if (subRecipeMode) { + // Clear ingredient reference when using a sub-recipe + Line.IngredientId = null; + } + else { + // Clear sub-recipe reference when using an ingredient + Line.SubRecipeId = null; + } + } + + // update Line.IngredientId from select change + private void OnIngredientChanged(ChangeEventArgs e) { + // try parse selected value as int + if (int.TryParse(e.Value?.ToString(), out int id)) + Line.IngredientId = id; // assign parsed id when valid + else + Line.IngredientId = null; // clear id when parse fails + } +} \ No newline at end of file From 8ccc6853fa43972ad57fb461e50422aec2090720 Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Fri, 27 Feb 2026 20:11:00 -0600 Subject: [PATCH 04/15] fix: delete unnecessary Ingredient entity --- .../Data/Entities/Ingredient.cs | 27 - .../Recipe/Pages/IngredientLineRow.razor | 48 +- docs/RecipeWorkflow.md | 1166 +++++++++++++++++ 3 files changed, 1211 insertions(+), 30 deletions(-) delete mode 100644 CulinaryCommandApp/Data/Entities/Ingredient.cs create mode 100644 docs/RecipeWorkflow.md diff --git a/CulinaryCommandApp/Data/Entities/Ingredient.cs b/CulinaryCommandApp/Data/Entities/Ingredient.cs deleted file mode 100644 index c78b7ea..0000000 --- a/CulinaryCommandApp/Data/Entities/Ingredient.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; -using CulinaryCommandApp.Recipe.Entities; - -namespace CulinaryCommand.Data.Entities -{ - public class Ingredient - { - [Key] - public int IngredientId { get; set; } - - [Required, MaxLength(128)] - public string Name { get; set; } - - [Required, MaxLength(128)] - public string Category { get; set; } - - [Required, MaxLength(128)] - public string DefaultUnit { get; set; } - - // Navigation - public ICollection RecipeIngredients { get; set; } = new List(); - } -} \ No newline at end of file diff --git a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor index 7a23a96..7006ae7 100644 --- a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor +++ b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor @@ -1,5 +1,5 @@ @using CulinaryCommand.Data.Entities - +@using CulinaryCommandApp.Inventory.Entities
@@ -31,7 +31,32 @@ } } + else { + + } + + +
@@ -45,6 +70,8 @@ private bool IsSubRecipeMode => Line.SubRecipeId.HasValue; private List availableIngredients = new(); private List ingredientCategories = new(); + private List availableSubRecipes = new(); + private List availableUnits = new (); private IEnumerable IngredientsByCategory(string category) => availableIngredients @@ -65,11 +92,26 @@ } // update Line.IngredientId from select change - private void OnIngredientChanged(ChangeEventArgs e) { + private void OnIngredientChanged(ChangeEventArgs ingredientChangeEvent) { // try parse selected value as int - if (int.TryParse(e.Value?.ToString(), out int id)) + if (int.TryParse(ingredientChangeEvent.Value?.ToString(), out int id)) Line.IngredientId = id; // assign parsed id when valid else Line.IngredientId = null; // clear id when parse fails } + + + private void OnSubRecipeChanged(ChangeEventArgs subRecipeChangeEvent) { + + if (int.TryParse(subRecipeChangeEvent.Value?.ToString(), out int id)) + Line.SubRecipeId = id; + else + Line.SubRecipe = null; + } + + + private void OnUnitChanged(ChangeEventArgs unitChangeEvent) { + if (int.TryParse(unitChangeEvent.Value?.ToString(), out int id)) + Line.UnitId = id; + } } \ No newline at end of file diff --git a/docs/RecipeWorkflow.md b/docs/RecipeWorkflow.md new file mode 100644 index 0000000..b0c7f8e --- /dev/null +++ b/docs/RecipeWorkflow.md @@ -0,0 +1,1166 @@ +# Recipe Workflow β€” Implementation Guide + +**Project:** Culinary Command +**Date:** February 26, 2026 +**Target Framework:** .NET 9.0 β€” Blazor Server (Interactive Server rendering) + +--- + +## Table of Contents + +- [Recipe Workflow β€” Implementation Guide](#recipe-workflow--implementation-guide) + - [Table of Contents](#table-of-contents) + - [1. Overview](#1-overview) + - [2. Current State Audit](#2-current-state-audit) + - [3. Design Principles \& Framework Alignment](#3-design-principles--framework-alignment) + - [Vertical Slice Architecture (Feature Folders)](#vertical-slice-architecture-feature-folders) + - [Interface-First Services](#interface-first-services) + - [Repository-Light (DbContext Directly in Services)](#repository-light-dbcontext-directly-in-services) + - [EF Core β€” Code-First Migrations](#ef-core--code-first-migrations) + - [Blazor Interactive Server Rendering](#blazor-interactive-server-rendering) + - [4. Domain Model \& Entity Design](#4-domain-model--entity-design) + - [4.1 Entity Descriptions](#41-entity-descriptions) + - [`Recipe`](#recipe) + - [`RecipeIngredient`](#recipeingredient) + - [`RecipeStep`](#recipestep) + - [`RecipeSubRecipe` *(new join/audit table β€” optional but recommended)*](#recipesubrecipe-new-joinaudit-table--optional-but-recommended) + - [4.2 Sub-Recipe (Nested Recipe) Pattern](#42-sub-recipe-nested-recipe-pattern) + - [Recursive Resolution Algorithm](#recursive-resolution-algorithm) + - [4.3 Key Design Decisions](#43-key-design-decisions) + - [5. ER Diagram](#5-er-diagram) + - [Reading the Diagram](#reading-the-diagram) + - [6. Database Schema Changes](#6-database-schema-changes) + - [6.1 Changes to Existing Tables](#61-changes-to-existing-tables) + - [`Recipe` β€” add two columns](#recipe--add-two-columns) + - [`RecipeIngredient` β€” make `IngredientId` nullable, add `SubRecipeId`](#recipeingredient--make-ingredientid-nullable-add-subrecipeid) + - [`RecipeStep` β€” increase `Instructions` to 2 048 characters](#recipestep--increase-instructions-to-2-048-characters) + - [6.2 New Table](#62-new-table) + - [`RecipeSubRecipe`](#recipesubrecipe) + - [6.3 Navigation Properties to Add to `Recipe`](#63-navigation-properties-to-add-to-recipe) + - [6.4 Generating the Migrations](#64-generating-the-migrations) + - [7. Location-Scoped Unit Management](#7-location-scoped-unit-management) + - [7.1 Unit Entity \& LocationUnit Join Table](#71-unit-entity--locationunit-join-table) + - [7.2 IUnitService Changes](#72-iunitservice-changes) + - [7.3 Blazor Unit Management UI](#73-blazor-unit-management-ui) + - [8. Inventory Catalog Integration](#8-inventory-catalog-integration) + - [8.1 Ingredient Source Constraint](#81-ingredient-source-constraint) + - [8.2 IIngredientService Changes](#82-iingredientservice-changes) + - [8.3 RecipeForm Ingredient Picker](#83-recipeform-ingredient-picker) + - [9. Service Layer](#9-service-layer) + - [9.1 `IRecipeService` Interface](#91-irecipeservice-interface) + - [9.2 Recursive Cost \& Ingredient Flattening](#92-recursive-cost--ingredient-flattening) + - [9.3 Circular Reference Guard](#93-circular-reference-guard) + - [10. Data Transfer Objects (DTOs)](#10-data-transfer-objects-dtos) + - [`RecipeSummaryDTO`](#recipesummarydto) + - [`RecipeDetailDTO`](#recipedetaildto) + - [`RecipeIngredientLineDTO`](#recipeingredientlinedto) + - [`RecipeStepDTO`](#recipestepdto) + - [`FlatIngredientLineDTO`](#flatingredientlinedto) + - [`ProduceRecipeRequest`](#producereciperequest) + - [11. Blazor Component Architecture](#11-blazor-component-architecture) + - [11.1 Page \& Component Breakdown](#111-page--component-breakdown) + - [Pages (thin route stubs)](#pages-thin-route-stubs) + - [Components (stateful, interactive)](#components-stateful-interactive) + - [Why extract `IngredientLineRow`?](#why-extract-ingredientlinerow) + - [11.2 Component Interaction Flow](#112-component-interaction-flow) + - [12. Inventory Integration](#12-inventory-integration) + - [13. Validation Strategy](#13-validation-strategy) + - [14. Migration Strategy](#14-migration-strategy) + - [15. Unit Testing Requirements](#15-unit-testing-requirements) + - [Service Tests (`CulinaryCommandUnitTests/Recipe/`)](#service-tests-culinarycommandunittestsrecipe) + - [Unit Management Service Tests (`CulinaryCommandUnitTests/Inventory/`)](#unit-management-service-tests-culinarycommandunittestsinventory) + - [Ingredient Service Tests (`CulinaryCommandUnitTests/Inventory/`)](#ingredient-service-tests-culinarycommandunittestsinventory) + - [Component Tests (`CulinaryCommandUnitTests/Recipe/`)](#component-tests-culinarycommandunittestsrecipe) + - [16. Implementation Checklist](#16-implementation-checklist) + - [Phase 1 β€” Schema \& Entities](#phase-1--schema--entities) + - [Phase 2 β€” Service Layer](#phase-2--service-layer) + - [Phase 3 β€” DTOs](#phase-3--dtos) + - [Phase 4 β€” Blazor Components](#phase-4--blazor-components) + - [Phase 5 β€” Testing](#phase-5--testing) + +--- + +## 1. Overview + +Recipes are a first-class domain object in Culinary Command. A recipe describes **what ingredients are needed**, **how much of each**, and **the procedure to prepare a dish**. Beyond single-level ingredient lists, professional kitchens rely heavily on **sub-recipes** β€” a preparatory item (e.g., "House Vinaigrette", "Beurre Blanc", "Brioche Dough") that is itself composed of raw ingredients and is then reused across multiple finished recipes. + +This document covers: + +- The full entity model required to support both flat and nested recipes. +- The reasoning behind every design decision. +- An ER diagram showing how every entity relates to the rest of the system. +- The service, DTO, and Blazor component architecture needed to implement it end-to-end. +- How recipe execution feeds back into the inventory system. + +--- + +## 2. Current State Audit + +The following recipe-related code already exists in the project and **must be preserved or extended**, not replaced. + +| Artifact | Location | Status | +|---|---|---| +| `Recipe` entity | `CulinaryCommandApp/Data/Entities/Recipe.cs` | Exists β€” needs `SubRecipes` navigation | +| `RecipeIngredient` entity | `CulinaryCommandApp/Data/Entities/RecipeIngredient.cs` | Exists β€” needs `SubRecipeId` FK column | +| `RecipeStep` entity | `CulinaryCommandApp/Data/Entities/RecipeStep.cs` | Exists β€” `Instructions` should grow to 2 048 chars | +| `RecipeService` | `CulinaryCommandApp/Services/RecipeService.cs` | Exists β€” must be extracted behind an interface | +| `RecipeForm.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor` | Exists β€” needs sub-recipe picker row and location-scoped ingredient/unit pickers | +| `RecipeList.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor` | Exists β€” minor additions needed | +| `RecipeView.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor` | Exists β€” needs nested section | +| `Create.razor` / `Edit.razor` | `CulinaryCommandApp/Components/Pages/Recipes/` | Exist β€” minimal changes | +| `EnumService.GetRecipeTypes()` | `CulinaryCommandApp/Services/EnumService.cs` | Exists β€” `PrepItem` type already included | +| `AppDbContext` | `CulinaryCommandApp/Data/AppDbContext.cs` | Exists β€” needs `RecipeSubRecipe` and `LocationUnit` `DbSet`s | +| `Inventory.Entities.Unit` | `CulinaryCommandApp/Inventory/Entities/Unit.cs` | Exists β€” global (no `LocationId`); needs `LocationUnit` join table for per-location scoping | +| `Data.Entities.MeasurementUnit` | `CulinaryCommandApp/Data/Entities/MeasurementUnit.cs` | Exists β€” recipe-scoped unit stub; **superseded** by the location-scoped `Unit` approach described in Β§7 | +| `IUnitService` / `UnitService` | `CulinaryCommandApp/Inventory/Services/` | Exists β€” needs location-aware query methods | +| `IIngredientService` / `IngredientService` | `CulinaryCommandApp/Inventory/Services/` | Exists β€” needs `GetByLocationAsync` method | +| `InventoryManagementService` | `CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs` | Exists β€” `GetItemsByLocationAsync` already scopes by `LocationId`; used as the data source for the ingredient picker | +| `InventoryCatalog.razor` | `CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor` | Exists β€” the authoritative ingredient list; recipe form must source all ingredient choices from this same data set | + +**Gaps identified:** + +1. No `IRecipeService` interface β€” the concrete class is injected directly. +2. `RecipeIngredient` cannot reference a sub-recipe as an "ingredient line" β€” it only holds an `IngredientId`. +3. No cost-rollup or ingredient-flattening logic exists. +4. No circular-reference guard for nested recipes. +5. `RecipeStep.Instructions` is capped at 256 characters, which is insufficient for multi-sentence instructions. +6. `Inventory.Entities.Unit` has no `LocationId` β€” units are global and cannot be customised per restaurant location. A `LocationUnit` join table is required (mirroring the `LocationVendor` pattern) so owners can configure which units are available at their location. +7. `IIngredientService` has no location-scoped query method β€” `RecipeForm` currently has no way to restrict ingredient selection to the ingredients catalogued at the active location (`/inventory-catalog`). A `GetByLocationAsync` method and corresponding DTO are needed. + +--- + +## 3. Design Principles & Framework Alignment + +### Vertical Slice Architecture (Feature Folders) + +Culinary Command already organises its code in feature folders (`Inventory/`, `PurchaseOrder/`, `Vendor/`). Recipes should follow the same convention: + +``` +CulinaryCommandApp/ + Recipe/ + DTOs/ + Entities/ ← RecipeSubRecipe lives here + Mapping/ + Pages/ ← thin page stubs (routes only) + Components/ ← UI components + Services/ + Interfaces/ +``` + +All new types live under `CulinaryCommand.Recipe.*` namespaces. + +### Interface-First Services + +Every service in the Inventory module is backed by an interface (`IIngredientService`, `IUnitService`, etc.) and registered with DI. `RecipeService` must be refactored to implement `IRecipeService` for the same reasons β€” testability, replaceability, and adherence to the project's own pattern. + +### Repository-Light (DbContext Directly in Services) + +The project does not use a separate Repository layer β€” all services depend directly on `AppDbContext`. This pattern should be maintained. Introducing a separate repository layer would be inconsistent with existing code and would add unnecessary abstraction without a demonstrated benefit at this scale. + +### EF Core β€” Code-First Migrations + +All schema changes must be expressed as EF Core migrations using `dotnet ef migrations add`. No raw SQL DDL should be written manually. + +### Blazor Interactive Server Rendering + +All recipe pages use `@rendermode InteractiveServer`, consistent with `InventoryManagement.razor` and existing recipe pages. SignalR keeps the component tree in sync without API controllers. + +--- + +## 4. Domain Model & Entity Design + +### 4.1 Entity Descriptions + +#### `Recipe` + +The core aggregate root. Represents a named, versioned set of instructions and ingredients scoped to a `Location`. + +| Column | Type | Notes | +|---|---|---| +| `RecipeId` | `int` PK | Identity | +| `LocationId` | `int` FK | Scoped to a restaurant location | +| `Title` | `string(128)` | Display name | +| `Category` | `string(128)` | e.g., "Produce", "Dairy" (from `Category` enum) | +| `RecipeType` | `string(128)` | e.g., "Entree", "Sauce", "Prep Item" (from `RecipeType` enum) | +| `YieldAmount` | `decimal?` | How much this recipe produces | +| `YieldUnit` | `string(128)` | Unit of the yield (e.g., "Liters", "Each") | +| `IsSubRecipe` | `bool` | `true` when this recipe is intended to be embedded in other recipes | +| `CostPerYield` | `decimal?` | Cached/computed total food cost per yield unit | +| `CreatedAt` | `datetime` | UTC timestamp | + +**Why add `IsSubRecipe`?** It is a query-time optimisation. When the `RecipeForm` presents a sub-recipe picker, it needs to filter the recipe list to only show items that make sense as building blocks. Scanning `RecipeType == "Prep Item"` would work but is fragile β€” a sauce or stock could also be a sub-recipe. A dedicated boolean is unambiguous and queryable with a simple index. + +**Why add `CostPerYield`?** Food cost is the primary business metric for any restaurant. Caching the rolled-up cost on the `Recipe` row avoids recursive database traversals on every page load. It is recomputed whenever a recipe is saved. + +--- + +#### `RecipeIngredient` + +A single line on a recipe β€” **either** a raw inventory ingredient **or** a sub-recipe. Exactly one of `IngredientId` and `SubRecipeId` must be non-null (enforced at the service layer and by a check constraint). + +| Column | Type | Notes | +|---|---|---| +| `RecipeIngredientId` | `int` PK | Identity | +| `RecipeId` | `int` FK | Parent recipe | +| `IngredientId` | `int?` FK | References `Inventory.Ingredients.Id` β€” null when line is a sub-recipe | +| `SubRecipeId` | `int?` FK | References `Recipe.RecipeId` β€” null when line is a raw ingredient | +| `UnitId` | `int` FK | References `Inventory.Units.Id` | +| `Quantity` | `decimal` | Amount of the ingredient or sub-recipe yield used | +| `PrepNote` | `string(256)` | e.g., "finely diced", "room temperature" | +| `SortOrder` | `int` | Display order within the recipe | + +**Why allow `SubRecipeId` on `RecipeIngredient` rather than a separate join table?** This is the most widely adopted pattern (used by ChefTec, MarginEdge, and Recipe.ly) because it keeps the ingredient-line concept cohesive. A recipe line is always a quantity of something β€” whether that something is a raw item or a finished prep. Using a separate join table would require two different loops in every consumer of the recipe, complicating both service and UI code. The nullable pair `(IngredientId | SubRecipeId)` with a check constraint clearly communicates the exclusivity invariant. + +--- + +#### `RecipeStep` + +An ordered instruction step for preparing the recipe. The `Instructions` column should be expanded from 256 to 2 048 characters to accommodate real-world step descriptions. + +| Column | Type | Notes | +|---|---|---| +| `StepId` | `int` PK | Identity | +| `RecipeId` | `int` FK | Parent recipe | +| `StepNumber` | `int` | 1-based ordinal | +| `Instructions` | `string(2048)` | Full preparation instruction | + +--- + +#### `RecipeSubRecipe` *(new join/audit table β€” optional but recommended)* + +When a sub-recipe is used as a line in a parent recipe, the relationship is already captured by `RecipeIngredient.SubRecipeId`. However, maintaining a direct `RecipeSubRecipe` join table between the two `Recipe` rows provides several benefits: + +- Enables a fast "where is this sub-recipe used?" reverse lookup without scanning `RecipeIngredient`. +- Makes circular-reference detection via a simple SQL query feasible. +- Mirrors how the `UserLocation` and `ManagerLocation` join tables handle M:M relationships in the existing schema. + +| Column | Type | Notes | +|---|---|---| +| `ParentRecipeId` | `int` FK | The recipe that includes the sub-recipe | +| `ChildRecipeId` | `int` FK | The sub-recipe being embedded | + +Composite PK: `(ParentRecipeId, ChildRecipeId)`. + +This table is **derived** from `RecipeIngredient` rows β€” it is populated automatically by `RecipeService` whenever a recipe is saved. It should never be written to directly by UI code. + +--- + +### 4.2 Sub-Recipe (Nested Recipe) Pattern + +A sub-recipe is a `Recipe` where `IsSubRecipe = true`. It is referenced on a parent recipe's ingredient list via `RecipeIngredient.SubRecipeId`. The depth of nesting is unbounded by the schema β€” a sub-recipe can itself contain another sub-recipe. + +**Example hierarchy:** + +``` +Caesar Salad (Entree) +β”œβ”€β”€ 2 heads Romaine Lettuce [raw ingredient] +β”œβ”€β”€ 0.5 cup Croutons [raw ingredient] +└── 1 oz Caesar Dressing [sub-recipe β†’ Prep Item] + β”œβ”€β”€ 0.25 cup Olive Oil [raw ingredient] + β”œβ”€β”€ 2 cloves Garlic [raw ingredient] + └── 1 tsp Anchovy Paste [raw ingredient] +``` + +When "Caesar Salad" is prepared (i.e., "produced"): + +1. Stock of Romaine Lettuce decreases by 2 heads. +2. Stock of Croutons decreases by 0.5 cup. +3. The service **recursively resolves** Caesar Dressing and deducts its constituent ingredients proportionally based on the quantity of dressing used. + +#### Recursive Resolution Algorithm + +``` +FlattenIngredients(recipeId, multiplier, visited): + if recipeId ∈ visited β†’ throw CircularReferenceException + add recipeId to visited + + for each line in RecipeIngredients where RecipeId = recipeId: + if line.IngredientId is not null: + yield (IngredientId, Quantity Γ— multiplier, UnitId) + else: + subYield = SubRecipe.YieldAmount ?? 1 + ratio = line.Quantity / subYield + yield from FlattenIngredients(line.SubRecipeId, multiplier Γ— ratio, visited) +``` + +The `visited` set prevents infinite loops when a recipe accidentally references itself through an indirect chain. This guard must be applied at save time (to reject the configuration) **and** at resolve time (as a defence-in-depth measure). + +--- + +### 4.3 Key Design Decisions + +| Decision | Rationale | +|---|---| +| Sub-recipe FK lives on `RecipeIngredient`, not a separate table | Keeps the "ingredient line" concept cohesive; one loop for consumers | +| `IsSubRecipe` boolean on `Recipe` | Efficient UI filtering; explicit intent over convention | +| `CostPerYield` cached on `Recipe` | Avoids recursive DB traversal on every render; recomputed on save | +| `RecipeSubRecipe` mirror table | Fast reverse lookup ("used in") and circular reference detection via SQL | +| Interface `IRecipeService` | Matches project pattern; enables unit testing without a DB | +| No separate Repository layer | Consistent with existing `IngredientService`, `UnitService`, etc. | +| `RecipeType` stays string constant (not DB enum) | Matches existing `Category` and `RecipeType` constant classes; avoids migrations for new types | +| `Location`-scoped recipes | Consistent with Inventory β€” every recipe belongs to one `Location`; a `Company` can have multiple locations with different menus | +| `LocationUnit` join table for per-location unit configuration | Mirrors `LocationVendor`; allows each restaurant to curate the units available in their recipe forms without altering the global `Unit` catalogue | +| Ingredient picker sourced exclusively from `/inventory-catalog` (location-scoped) | Guarantees that every ingredient on a recipe exists in the location's live inventory, enabling accurate stock deduction and cost calculation | +| `MeasurementUnit` entity superseded by location-scoped `Unit` usage | `Data.Entities.MeasurementUnit` was an incomplete stub; the authoritative unit entity is `Inventory.Entities.Unit`, now scoped via `LocationUnit` | + +--- + +## 5. ER Diagram + +```mermaid +erDiagram + direction TB + + Company { + int Id PK + string Name + string CompanyCode + } + + Location { + int Id PK + int CompanyId FK + string Name + string Address + } + + User { + int Id PK + int CompanyId FK + string Name + string Role + } + + Recipe { + int RecipeId PK + int LocationId FK + string Title + string Category + string RecipeType + decimal YieldAmount + string YieldUnit + bool IsSubRecipe + decimal CostPerYield + datetime CreatedAt + } + + RecipeIngredient { + int RecipeIngredientId PK + int RecipeId FK + int IngredientId FK "nullable" + int SubRecipeId FK "nullable" + int UnitId FK + decimal Quantity + string PrepNote + int SortOrder + } + + RecipeStep { + int StepId PK + int RecipeId FK + int StepNumber + string Instructions + } + + RecipeSubRecipe { + int ParentRecipeId PK,FK + int ChildRecipeId PK,FK + } + + Ingredient { + int Id PK + int LocationId FK + int UnitId FK + int VendorId FK "nullable" + string Name + string Category + decimal StockQuantity + decimal ReorderLevel + decimal Price + } + + Unit { + int Id PK + string Name + string Abbreviation + decimal ConversionFactor + } + + LocationUnit { + int LocationId PK,FK + int UnitId PK,FK + datetime AssignedAt + } + + InventoryTransaction { + int Id PK + int IngredientId FK + int UnitId FK + decimal StockChange + string Reason + datetime CreatedAt + } + + Vendor { + int Id PK + int CompanyId FK + string Name + } + + Company ||--o{ Location : "has" + Company ||--o{ User : "employs" + Location ||--o{ Recipe : "owns" + Location ||--o{ LocationUnit : "configures" + Unit ||--o{ LocationUnit : "available at" + Recipe ||--o{ RecipeIngredient : "contains" + Recipe ||--o{ RecipeStep : "has" + Recipe ||--o{ RecipeSubRecipe : "parent of" + Recipe ||--o{ RecipeSubRecipe : "child of" + RecipeIngredient }o--o| Ingredient : "uses raw" + RecipeIngredient }o--o| Recipe : "uses sub-recipe" + RecipeIngredient ||--|| Unit : "measured in" + Ingredient ||--o{ InventoryTransaction : "tracked by" + Ingredient ||--|| Unit : "base unit" + Ingredient }o--o| Vendor : "supplied by" + Location ||--o{ Ingredient : "stocks" +``` + +### Reading the Diagram + +- **`Company β†’ Location β†’ Recipe`** β€” The ownership chain. Every recipe belongs to exactly one location, which belongs to exactly one company. This scopes the recipe book correctly for multi-tenant deployments. +- **`Location β†’ LocationUnit ← Unit`** β€” Each location configures its own set of allowed units (e.g., a bakery might enable "grams" and "cups" but not "fluid ounces"). This mirrors the `LocationVendor` pattern already used in the Vendor module. +- **`Recipe β†’ RecipeIngredient`** β€” One-to-many; a recipe has zero or more ingredient lines. +- **`RecipeIngredient β†’ Ingredient`** (nullable) β€” When a line references a raw ingredient, this FK is populated. The ingredient **must** belong to the same `LocationId` as the parent recipe β€” enforced at the service layer and pre-filtered in the UI picker. +- **`RecipeIngredient β†’ Unit`** β€” The unit used on this line must be one that is enabled for the location via `LocationUnit`. +- **`RecipeIngredient β†’ Recipe`** (nullable, `SubRecipeId`) β€” When a line references a sub-recipe, this FK is populated instead. The same `RecipeIngredient` entity handles both cases, which is the discriminated-union pattern. +- **`Recipe β†’ RecipeSubRecipe ← Recipe`** β€” The mirror join table sits between two `Recipe` rows, recording which recipes embed which. The double `Recipe` arrow reflects the self-referential many-to-many. +- **`Ingredient β†’ InventoryTransaction`** β€” When a recipe is produced, the service flattens all raw ingredients and creates one `InventoryTransaction` per unique ingredient, using the existing `InventoryTransactionService`. + +--- + +## 6. Database Schema Changes + +### 6.1 Changes to Existing Tables + +#### `Recipe` β€” add two columns + +```csharp +public bool IsSubRecipe { get; set; } = false; +public decimal? CostPerYield { get; set; } +``` + +#### `RecipeIngredient` β€” make `IngredientId` nullable, add `SubRecipeId` + +```csharp +public int? IngredientId { get; set; } +public int? SubRecipeId { get; set; } +public Recipe? SubRecipe { get; set; } +``` + +The existing `IngredientId` column is currently non-nullable (it has no `?`). Making it nullable is a **breaking schema change** β€” a migration is required and existing rows must be verified to be unaffected. + +#### `RecipeStep` β€” increase `Instructions` to 2 048 characters + +```csharp +[MaxLength(2048)] +public string? Instructions { get; set; } +``` + +### 6.2 New Table + +#### `RecipeSubRecipe` + +```csharp +namespace CulinaryCommand.Recipe.Entities +{ + public class RecipeSubRecipe + { + public int ParentRecipeId { get; set; } + public Recipe? ParentRecipe { get; set; } + + public int ChildRecipeId { get; set; } + public Recipe? ChildRecipe { get; set; } + } +} +``` + +Registered in `AppDbContext`: + +```csharp +public DbSet RecipeSubRecipes => Set(); +``` + +Fluent configuration in `OnModelCreating`: + +```csharp +modelBuilder.Entity() + .HasKey(rs => new { rs.ParentRecipeId, rs.ChildRecipeId }); + +modelBuilder.Entity() + .HasOne(rs => rs.ParentRecipe) + .WithMany(r => r.SubRecipeUsages) + .HasForeignKey(rs => rs.ParentRecipeId) + .OnDelete(DeleteBehavior.Cascade); + +modelBuilder.Entity() + .HasOne(rs => rs.ChildRecipe) + .WithMany(r => r.UsedInRecipes) + .HasForeignKey(rs => rs.ChildRecipeId) + .OnDelete(DeleteBehavior.Restrict); +``` + +`DeleteBehavior.Restrict` on the child side prevents accidentally orphaning sub-recipe references when the child recipe is deleted. The service layer should check for usages before allowing deletion of a sub-recipe. + +### 6.3 Navigation Properties to Add to `Recipe` + +```csharp +public ICollection SubRecipeUsages { get; set; } = new List(); +public ICollection UsedInRecipes { get; set; } = new List(); +``` + +### 6.4 Generating the Migrations + +```bash +dotnet ef migrations add AddSubRecipeSupport \ + --project CulinaryCommandApp \ + --startup-project CulinaryCommandApp +``` + +> See Β§14 (Migration Strategy) for the full ordered sequence, which now includes the `LocationUnit` table and the retirement of `MeasurementUnit`. + +--- + +## 7. Location-Scoped Unit Management + +### 7.1 Unit Entity & LocationUnit Join Table + +`Inventory.Entities.Unit` is currently a **global** catalogue β€” every location sees every unit. Restaurant owners need to configure a curated subset of units for their location (e.g., a bakery enables grams and cups; a bar enables millilitres and fluid ounces). This is modelled with a `LocationUnit` join table, exactly mirroring how `LocationVendor` works in the Vendor module. + +Create `CulinaryCommandApp/Inventory/Entities/LocationUnit.cs`: + +```csharp +using System.Text.Json.Serialization; +using CulinaryCommand.Data.Entities; + +namespace CulinaryCommand.Inventory.Entities +{ + /// + /// Join table linking a Unit to a specific Location. + /// Tracks which units of measurement are enabled for a given restaurant location. + /// + public class LocationUnit + { + public int LocationId { get; set; } + + [JsonIgnore] + public Location Location { get; set; } = default!; + + public int UnitId { get; set; } + + [JsonIgnore] + public Unit Unit { get; set; } = default!; + + public DateTime AssignedAt { get; set; } = DateTime.UtcNow; + } +} +``` + +Register in `AppDbContext`: + +```csharp +public DbSet LocationUnits => Set(); +``` + +Fluent configuration in `OnModelCreating`: + +```csharp +modelBuilder.Entity() + .HasKey(lu => new { lu.LocationId, lu.UnitId }); + +modelBuilder.Entity() + .HasOne(lu => lu.Location) + .WithMany(l => l.LocationUnits) + .HasForeignKey(lu => lu.LocationId); + +modelBuilder.Entity() + .HasOne(lu => lu.Unit) + .WithMany(u => u.LocationUnits) + .HasForeignKey(lu => lu.UnitId); +``` + +Add the corresponding navigation properties: + +- `Location` entity: `public ICollection LocationUnits { get; set; } = new List();` +- `Unit` entity: `public ICollection LocationUnits { get; set; } = new List();` + +**Why not add `LocationId` directly to `Unit`?** A unit like "grams" is not owned by one location β€” it is a shared reference. The join table lets multiple locations enable the same unit independently, and lets each location disable units they never use, keeping their dropdowns clean. + +**`MeasurementUnit` retirement:** `Data.Entities.MeasurementUnit` was an incomplete stub that duplicated `Inventory.Entities.Unit` without location scoping. Now that `Unit` is location-scoped via `LocationUnit`, `MeasurementUnit` should be marked obsolete and removed in a follow-up cleanup migration. Until that migration runs, `AppDbContext.MeasurementUnits` can remain registered but should not be referenced by any new code. + +--- + +### 7.2 IUnitService Changes + +Add two location-scoped methods to `IUnitService` (`CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs`): + +```csharp +/// Returns only the units enabled for a specific location. +Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default); + +/// Sets the complete list of units enabled at a location, adding/removing as needed. +Task SetLocationUnitsAsync(int locationId, IEnumerable unitIds, CancellationToken cancellationToken = default); +``` + +`UnitService` implements these by querying `_db.LocationUnits` β€” the same pattern used by `VendorService.GetVendorsByLocationAsync` and `VendorService.SetLocationVendorsAsync`. + +--- + +### 7.3 Blazor Unit Management UI + +Create a unit-configuration page under the Inventory feature folder, modelled after the existing Vendor management pages: + +| File | Route | Purpose | +|---|---|---| +| `CulinaryCommandApp/Inventory/Pages/Units/Index.razor` | `/units` | List all global units; shows which are enabled for the current location | +| `CulinaryCommandApp/Inventory/Pages/Units/UnitForm.razor` | (component) | Inline form for creating/editing a unit (name, abbreviation, conversion factor) | + +The `Index.razor` page renders a two-column layout: +- **Left:** all available units in the global catalogue with an enable/disable toggle per location. +- **Right:** a form to create a new unit (which is immediately added to the global catalogue; the owner can then enable it for their location). + +Add a "Units" link to `NavMenu.razor` under the Inventory section, consistent with the existing "Vendors" link pattern. + +**Role gating:** Only managers and admins (the same `priv` flag check used on recipe and vendor pages) may create, edit, or assign units. Read-only employees see the enabled units list but cannot modify it. + +--- + +## 8. Inventory Catalog Integration + +### 8.1 Ingredient Source Constraint + +When building a recipe, the ingredient picker in `IngredientLineRow` must show **only** the ingredients that are catalogued at the current location β€” i.e., the exact same data set that powers `/inventory-catalog`. This enforces a hard invariant: every ingredient on a recipe has a known stock record at the location, which makes cost calculation and inventory deduction reliable. + +`Inventory.Entities.Ingredient` already has a `LocationId` FK. `InventoryManagementService.GetItemsByLocationAsync(locationId)` already filters on this column and is the existing data source for the catalog page. The recipe form must use this same method (or a thin wrapper) rather than the unscoped `IIngredientService.GetAllAsync`. + +**Why not allow ingredients from other locations?** A recipe belongs to one location. If it could reference an ingredient from a different location, the service would have no valid stock row to deduct from when the recipe is produced. Cross-location sharing is a future concern and should be designed explicitly when needed. + +--- + +### 8.2 IIngredientService Changes + +Add one location-scoped method to `IIngredientService` (`CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs`): + +```csharp +/// +/// Returns all ingredients belonging to a specific location, +/// matching the data shown on the /inventory-catalog page. +/// +Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default); +``` + +Implement in `IngredientService`: + +```csharp +public async Task> GetByLocationAsync( + int locationId, CancellationToken cancellationToken = default) +{ + return await _db.Ingredients + .AsNoTracking() + .Include(i => i.Unit) + .Where(i => i.LocationId == locationId) + .OrderBy(i => i.Category) + .ThenBy(i => i.Name) + .ToListAsync(cancellationToken); +} +``` + +This is a direct analogue to `InventoryManagementService.GetItemsByLocationAsync` but returns the raw `Ingredient` entity (with its `Unit` navigation loaded) rather than a display DTO, which is what the recipe service needs for cost calculation. + +--- + +### 8.3 RecipeForm Ingredient Picker + +`IngredientLineRow.razor` currently populates `AvailableIngredients` from an unscoped call. This must be updated: + +1. `IngredientLineRow` receives the current `LocationId` as a `[Parameter]`. +2. On initialisation (or when switching to "raw ingredient" mode), it calls `IIngredientService.GetByLocationAsync(LocationId)`. +3. The ingredient dropdown is populated from this location-scoped list β€” identical to the `/inventory-catalog` catalogue. +4. The unit dropdown for the line is populated from `IUnitService.GetByLocationAsync(LocationId)` β€” the location's configured units. + +Similarly, the unit dropdown on `RecipeForm` for the recipe's own `YieldUnit` field must be populated from the location's configured units rather than the global `Unit` table. + +**Cascaded category filter (optional enhancement):** The ingredient picker may optionally offer a category pre-filter (mirroring the category filter on `/inventory-catalog`) to help users find ingredients quickly. Since `Ingredient.Category` is already populated in the data, this requires no schema change β€” just a client-side `@bind` filter on the dropdown list. + +--- + +## 9. Service Layer + +### 9.1 `IRecipeService` Interface + +Create `CulinaryCommandApp/Recipe/Services/Interfaces/IRecipeService.cs`: + +```csharp +using CulinaryCommand.Data.Entities; +using CulinaryCommand.Recipe.DTOs; + +namespace CulinaryCommand.Recipe.Services.Interfaces +{ + public interface IRecipeService + { + Task> GetAllByLocationAsync(int locationId, CancellationToken ct = default); + Task GetDetailAsync(int recipeId, CancellationToken ct = default); + Task> GetSubRecipesForLocationAsync(int locationId, CancellationToken ct = default); + Task CreateAsync(Data.Entities.Recipe recipe, CancellationToken ct = default); + Task UpdateAsync(Data.Entities.Recipe recipe, CancellationToken ct = default); + Task DeleteAsync(int recipeId, CancellationToken ct = default); + Task> FlattenIngredientsAsync(int recipeId, decimal multiplier = 1m, CancellationToken ct = default); + Task DeductInventoryAsync(int recipeId, decimal servingsProduced, CancellationToken ct = default); + } +} +``` + +**Why `RecipeSummaryDTO` and `RecipeDetailDTO`?** Returning the raw EF entity from a service is acceptable in this project (as seen in `RecipeService.GetAllByLocationIdAsync`) but the detail view needs richer data (ingredient names, sub-recipe titles, nested cost breakdowns) that would require multiple `Include` chains. Projecting into DTOs at the service layer avoids lazy-loading pitfalls and keeps Blazor components free of database concerns. + +--- + +### 9.2 Recursive Cost & Ingredient Flattening + +Create `CulinaryCommandApp/Recipe/Services/RecipeService.cs`. Key method: + +```csharp +public async Task> FlattenIngredientsAsync( + int recipeId, decimal multiplier = 1m, CancellationToken ct = default) +{ + return await FlattenCoreAsync(recipeId, multiplier, new HashSet(), ct); +} + +private async Task> FlattenCoreAsync( + int recipeId, decimal multiplier, HashSet visited, CancellationToken ct) +{ + if (!visited.Add(recipeId)) + throw new InvalidOperationException( + $"Circular sub-recipe reference detected at RecipeId {recipeId}."); + + var lines = await _db.RecipeIngredients + .AsNoTracking() + .Include(ri => ri.Ingredient) + .Include(ri => ri.Unit) + .Include(ri => ri.SubRecipe) + .Where(ri => ri.RecipeId == recipeId) + .ToListAsync(ct); + + var result = new List(); + + foreach (var line in lines) + { + if (line.IngredientId.HasValue) + { + result.Add(new FlatIngredientLineDTO + { + IngredientId = line.IngredientId.Value, + IngredientName = line.Ingredient!.Name, + Quantity = line.Quantity * multiplier, + UnitId = line.UnitId, + UnitName = line.Unit?.Name ?? string.Empty + }); + } + else if (line.SubRecipeId.HasValue) + { + var subYield = line.SubRecipe?.YieldAmount ?? 1m; + var ratio = line.Quantity / subYield; + var nested = await FlattenCoreAsync( + line.SubRecipeId.Value, multiplier * ratio, visited, ct); + result.AddRange(nested); + } + } + + visited.Remove(recipeId); + return result; +} +``` + +**Why remove `recipeId` from `visited` after processing?** The visited set guards against *cycles*, not against a sub-recipe being reused in multiple unrelated branches of the same recipe (diamond dependency). Removing the ID after a branch completes allows legitimate multi-use without false positives. + +--- + +### 9.3 Circular Reference Guard + +A guard must also run at **save time**, not just at flatten time. Before persisting any `RecipeIngredient` with a `SubRecipeId`, the service must verify the candidate sub-recipe does not (directly or transitively) reference the parent recipe. + +```csharp +private async Task WouldCreateCycleAsync( + int parentRecipeId, int proposedChildId, CancellationToken ct) +{ + var visited = new HashSet { parentRecipeId }; + var queue = new Queue(); + queue.Enqueue(proposedChildId); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!visited.Add(current)) + return true; + + var children = await _db.RecipeSubRecipes + .Where(rs => rs.ParentRecipeId == current) + .Select(rs => rs.ChildRecipeId) + .ToListAsync(ct); + + foreach (var child in children) + queue.Enqueue(child); + } + + return false; +} +``` + +This BFS traversal leverages the `RecipeSubRecipe` mirror table for efficient graph traversal, avoiding a full `RecipeIngredient` scan. + +--- + +## 10. Data Transfer Objects (DTOs) + +Create under `CulinaryCommandApp/Recipe/DTOs/`: + +### `RecipeSummaryDTO` + +Used in list views β€” lightweight, no nested data. + +```csharp +public class RecipeSummaryDTO +{ + public int RecipeId { get; set; } + public string Title { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string RecipeType { get; set; } = string.Empty; + public decimal? YieldAmount { get; set; } + public string YieldUnit { get; set; } = string.Empty; + public bool IsSubRecipe { get; set; } + public decimal? CostPerYield { get; set; } +} +``` + +### `RecipeDetailDTO` + +Used in the detail/view page β€” includes flattened ingredient lines and steps. + +```csharp +public class RecipeDetailDTO +{ + public int RecipeId { get; set; } + public string Title { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string RecipeType { get; set; } = string.Empty; + public decimal? YieldAmount { get; set; } + public string YieldUnit { get; set; } = string.Empty; + public bool IsSubRecipe { get; set; } + public decimal? CostPerYield { get; set; } + public List Ingredients { get; set; } = new(); + public List Steps { get; set; } = new(); + public List UsedInRecipes { get; set; } = new(); +} +``` + +### `RecipeIngredientLineDTO` + +Represents one line in the recipe, abstracting over raw vs. sub-recipe. + +```csharp +public class RecipeIngredientLineDTO +{ + public int RecipeIngredientId { get; set; } + public int SortOrder { get; set; } + public decimal Quantity { get; set; } + public string UnitName { get; set; } = string.Empty; + public int UnitId { get; set; } + public string PrepNote { get; set; } = string.Empty; + + public bool IsSubRecipe => SubRecipeId.HasValue; + + public int? IngredientId { get; set; } + public string? IngredientName { get; set; } + + public int? SubRecipeId { get; set; } + public string? SubRecipeTitle { get; set; } +} +``` + +### `RecipeStepDTO` + +```csharp +public class RecipeStepDTO +{ + public int StepId { get; set; } + public int StepNumber { get; set; } + public string Instructions { get; set; } = string.Empty; +} +``` + +### `FlatIngredientLineDTO` + +Used internally by the service for cost rollup and inventory deduction β€” never sent to the UI directly. + +```csharp +public class FlatIngredientLineDTO +{ + public int IngredientId { get; set; } + public string IngredientName { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public int UnitId { get; set; } + public string UnitName { get; set; } = string.Empty; +} +``` + +### `ProduceRecipeRequest` + +Triggers inventory deduction when a recipe is produced/executed. + +```csharp +public class ProduceRecipeRequest +{ + public int RecipeId { get; set; } + public decimal ServingsProduced { get; set; } = 1m; + public string? Notes { get; set; } +} +``` + +--- + +## 11. Blazor Component Architecture + +### 11.1 Page & Component Breakdown + +All files live under `CulinaryCommandApp/Components/Pages/Recipes/` (pages) and a new `CulinaryCommandApp/Recipe/Components/` folder (reusable components). + +#### Pages (thin route stubs) + +| File | Route | Purpose | +|---|---|---| +| `Index.razor` | `/recipes` | Hosts `RecipeList` | +| `Create.razor` | `/recipes/create` | Hosts `RecipeForm` in create mode | +| `Edit.razor` | `/recipes/edit/{id:int}` | Hosts `RecipeForm` in edit mode | +| `RecipeView.razor` | `/recipes/view/{id:int}` | Hosts `RecipeDetailView` | + +#### Components (stateful, interactive) + +| Component | Location | Responsibility | +|---|---|---| +| `RecipeList.razor` | `Components/Pages/Recipes/` | Table of recipes for current location; search, filter, navigate | +| `RecipeForm.razor` | `Components/Pages/Recipes/` | Create/edit recipe; ingredient lines; sub-recipe picker; step editor | +| `RecipeDetailView.razor` | `Recipe/Components/` | Read-only recipe display with nested sub-recipe expansion | +| `IngredientLineRow.razor` | `Recipe/Components/` | A single ingredient line within `RecipeForm` β€” handles toggle between raw and sub-recipe mode | +| `SubRecipePicker.razor` | `Recipe/Components/` | Dropdown/search to select a sub-recipe from the same location | +| `RecipeCostBadge.razor` | `Recipe/Components/` | Inline display of `CostPerYield` with currency formatting | +| `ProduceRecipeDialog.razor` | `Recipe/Components/` | Modal that accepts servings count and triggers inventory deduction | + +#### Why extract `IngredientLineRow`? + +The current `RecipeForm.razor` renders all ingredient lines inline in a `@foreach` loop. Adding sub-recipe support introduces a toggle (raw vs. sub-recipe), conditional dropdowns, and nested display logic. Extracting each line to its own component keeps `RecipeForm` readable, allows the row to manage its own state (selected category, loaded ingredients list, sub-recipe mode), and makes it independently testable using `bUnit`. + +--- + +### 11.2 Component Interaction Flow + +``` +RecipeForm [LocationId param] + β”œβ”€β”€ [n Γ— IngredientLineRow] [LocationId param] + β”‚ β”œβ”€β”€ mode: "raw" β†’ CategoryFilter (client-side) β†’ IngredientSelect + β”‚ β”‚ (IIngredientService.GetByLocationAsync(LocationId)) + β”‚ β”‚ β†’ UnitSelect + β”‚ β”‚ (IUnitService.GetByLocationAsync(LocationId)) + β”‚ └── mode: "sub-recipe" β†’ SubRecipePicker + β”‚ (IRecipeService.GetSubRecipesForLocationAsync(LocationId)) + β”œβ”€β”€ YieldUnit select (IUnitService.GetByLocationAsync(LocationId)) + β”œβ”€β”€ [n Γ— StepEditor] (inline textarea rows, no extraction needed yet) + └── OnValidSubmit ──────────→ IRecipeService.CreateAsync / UpdateAsync + └── ValidateIngredientsBelongToLocation() + └── ValidateUnitsEnabledForLocation() + └── ValidateNoCycle() + └── RecalculateCost() β†’ updates CostPerYield + └── SyncRecipeSubRecipeTable() + +RecipeDetailView + β”œβ”€β”€ RecipeCostBadge + β”œβ”€β”€ [n Γ— IngredientLineRow (read-only)] + β”‚ └── if IsSubRecipe β†’ nested RecipeDetailView (collapsible) + └── ProduceRecipeDialog (manager/admin only) + └── IRecipeService.DeductInventoryAsync() + └── FlattenIngredients() β†’ IInventoryTransactionService.RecordAsync() Γ— N +``` + +**Why is `ProduceRecipeDialog` on the detail view?** Producing a recipe is an intentional, high-consequence action (it modifies stock). Placing it behind a confirmation modal on the detail view (rather than the list) ensures the user has reviewed the recipe before committing inventory changes. Role gating (`priv` flag, as used in `RecipeList.razor`) should restrict the "Produce" button to managers and admins. + +--- + +## 12. Inventory Integration + +When a recipe is "produced" (`DeductInventoryAsync`): + +1. Call `FlattenIngredientsAsync(recipeId, servingsProduced)` to get the complete list of raw ingredients and quantities. +2. Group lines by `IngredientId` and sum quantities (a sub-recipe's ingredient may appear in multiple branches). +3. For each grouped ingredient, call `IInventoryTransactionService.RecordAsync` with a negative `StockChange` and `Reason = $"Recipe production: {recipe.Title}"`. +4. All `RecordAsync` calls must succeed or all must roll back β€” wrap in a `using var tx = await _db.Database.BeginTransactionAsync()` block inside the service. This mirrors the existing transaction pattern in `InventoryTransactionService.RecordAsync`. + +**Why use the existing `InventoryTransactionService`?** It already contains the conditional update SQL (`UPDATE Ingredients SET StockQuantity = StockQuantity + {delta} WHERE ...`) that prevents stock going negative in a concurrent environment. Reusing it avoids duplicating this critical concurrency logic. + +**Unit conversion note:** `RecipeIngredient.UnitId` references `Inventory.Units` which carries a `ConversionFactor`. When deducting stock, the service must convert the recipe quantity from the recipe's measurement unit to the ingredient's base stock unit before deducting. Example: recipe specifies "500 ml" cream, but the ingredient is tracked in "liters" β€” deduct 0.5 L. + +--- + +## 13. Validation Strategy + +All validation is enforced in two places: the service layer (always) and the Blazor form (for user feedback). + +| Rule | Where enforced | +|---|---| +| `Recipe.Title` required, max 128 chars | Data annotation + service guard | +| `Recipe.Category` must be a valid `Category` constant | Service guard | +| `Recipe.RecipeType` must be a valid `RecipeType` constant | Service guard | +| Each `RecipeIngredient` line: exactly one of `IngredientId` / `SubRecipeId` non-null | Service guard + Blazor toggle logic | +| Each `RecipeIngredient` line: `Quantity > 0` | Data annotation | +| `Recipe.YieldAmount > 0` when `YieldUnit` is set | Service guard | +| Sub-recipe reference must not create a circular dependency | `WouldCreateCycleAsync()` in service | +| Sub-recipe must belong to the same `LocationId` as the parent recipe | Service guard | +| Cannot delete a recipe that is referenced as a sub-recipe by another recipe | Service `DeleteAsync` pre-check | +| `RecipeIngredient.IngredientId` must reference an ingredient belonging to the same `LocationId` as the recipe | Service guard β€” queries `Ingredient.LocationId == recipe.LocationId` before save | +| `RecipeIngredient.UnitId` must reference a unit enabled for the recipe's location via `LocationUnit` | Service guard β€” queries `LocationUnits` before save | +| `Recipe.YieldUnit` (when stored as a `UnitId`) must be a unit enabled for the location | Service guard | +| Cannot delete a `Unit` that is still referenced by any `RecipeIngredient` or `Ingredient` at that location | `UnitService.DeleteAsync` pre-check | +| Cannot disable (remove from `LocationUnit`) a unit that is still used on an active ingredient or recipe line at that location | Service guard in `SetLocationUnitsAsync` | + +**Blazor form validation** should use `` with `` and ``. For the sub-recipe cycle check, since it requires a DB round-trip, it should be surfaced as a non-field error message rendered in a `
` block after the `Save` button is clicked. + +--- + +## 14. Migration Strategy + +Migrations must be applied in order. The recommended sequence is: + +1. **`AddIsSubRecipeAndCostPerYield`** β€” adds `IsSubRecipe` (bit, default 0) and `CostPerYield` (decimal, nullable) to `Recipes`. +2. **`MakeRecipeIngredientIngredientIdNullable`** β€” alters `IngredientId` to nullable and adds `SubRecipeId` (int, nullable, FK to `Recipes(RecipeId)`). +3. **`AddRecipeSubRecipeTable`** β€” creates the `RecipeSubRecipes` join table with composite PK and both FKs. +4. **`ExpandRecipeStepInstructions`** β€” increases `Instructions` from `nvarchar(256)` to `nvarchar(2048)`. +5. **`AddLocationUnitTable`** β€” creates the `LocationUnits` join table (`LocationId`, `UnitId`, `AssignedAt`) with composite PK and FKs to `Locations` and `Units`. Seed: for each existing `Location`, insert a `LocationUnit` row for every `Unit` currently in the database so no existing location loses access to units after the migration. +6. **`DeprecateMeasurementUnit`** *(deferred cleanup)* β€” drops the `MeasurementUnits` table and removes the corresponding `DbSet` registration. This migration should only run after confirming no production rows exist in `MeasurementUnits`. + +Each migration should be reviewed in its generated `.cs` file before applying. Verify that step 2 does not drop and recreate the existing `RecipeIngredient` table β€” check the `MigrationBuilder` output carefully. For step 5, review the seed logic to ensure all existing locations receive sensible default units. + +--- + +## 15. Unit Testing Requirements + +Tests live in `CulinaryCommandUnitTests/`. The project already uses `bUnit` for Blazor component tests. + +### Service Tests (`CulinaryCommandUnitTests/Recipe/`) + +| Test Class | Covers | +|---|---| +| `RecipeServiceFlattenTests` | Single-level, two-level nested, diamond dependency, circular reference detection | +| `RecipeServiceCostTests` | Cost rollup correctness for flat and nested recipes | +| `RecipeServiceCycleDetectionTests` | `WouldCreateCycleAsync` returns true for direct and indirect cycles | +| `RecipeServiceProduceTests` | `DeductInventoryAsync` calls `IInventoryTransactionService.RecordAsync` with correct quantities; rollback on insufficient stock | +| `RecipeServiceIngredientScopeTests` | `CreateAsync` / `UpdateAsync` reject ingredients whose `LocationId` does not match the recipe's location | +| `RecipeServiceUnitScopeTests` | `CreateAsync` / `UpdateAsync` reject units not present in `LocationUnits` for the recipe's location | + +### Unit Management Service Tests (`CulinaryCommandUnitTests/Inventory/`) + +| Test Class | Covers | +|---|---| +| `UnitServiceLocationTests` | `GetByLocationAsync` returns only units with a matching `LocationUnit` row; `SetLocationUnitsAsync` adds and removes rows correctly | +| `UnitServiceDeleteGuardTests` | `DeleteAsync` throws when unit is referenced by an active `RecipeIngredient` or `Ingredient` | +| `UnitServiceDisableGuardTests` | `SetLocationUnitsAsync` throws when attempting to remove a unit still in use at that location | + +### Ingredient Service Tests (`CulinaryCommandUnitTests/Inventory/`) + +| Test Class | Covers | +|---|---| +| `IngredientServiceLocationTests` | `GetByLocationAsync` returns only ingredients with matching `LocationId`; ordering by category then name | + +### Component Tests (`CulinaryCommandUnitTests/Recipe/`) + +| Test Class | Covers | +|---|---| +| `IngredientLineRowTests` | Toggle between raw and sub-recipe mode; ingredient dropdown populated from location-scoped list; unit dropdown populated from location-configured units | +| `RecipeFormTests` | Form submits valid data; shows error when cycle detected; ingredient and unit dropdowns are empty when no location context provided | +| `RecipeDetailViewTests` | Nested sub-recipe section renders; "Produce" button hidden for non-managers | + +All service tests should use an in-memory EF Core database (`UseInMemoryDatabase`) or Moq for `AppDbContext` to avoid a live DB dependency. + +--- + +## 16. Implementation Checklist + +Use this checklist to track progress. Complete items in order β€” later steps depend on earlier ones. + +### Phase 1 β€” Schema & Entities +- [x] Add `IsSubRecipe` and `CostPerYield` to `Recipe` entity +- [ ] Make `RecipeIngredient.IngredientId` nullable; add `SubRecipeId` and `SubRecipe` navigation +- [ ] Expand `RecipeStep.Instructions` to `MaxLength(2048)` +- [ ] Create `RecipeSubRecipe` entity under `CulinaryCommandApp/Recipe/Entities/` +- [ ] Add `SubRecipeUsages` and `UsedInRecipes` navigation to `Recipe` +- [ ] Register `RecipeSubRecipes` `DbSet` in `AppDbContext` +- [ ] Add Fluent API configuration for `RecipeSubRecipe` in `OnModelCreating` +- [ ] Create `LocationUnit` entity under `CulinaryCommandApp/Inventory/Entities/` +- [ ] Add `LocationUnits` navigation to `Location` entity +- [ ] Add `LocationUnits` navigation to `Unit` entity +- [ ] Register `LocationUnits` `DbSet` in `AppDbContext` +- [ ] Add Fluent API configuration for `LocationUnit` in `OnModelCreating` +- [ ] Generate and review all six migrations (including `AddLocationUnitTable` and deferred `DeprecateMeasurementUnit`) +- [ ] Apply migrations to development database; verify seed populates `LocationUnits` for existing locations + +### Phase 2 β€” Service Layer +- [ ] Add `GetByLocationAsync` to `IIngredientService` and implement in `IngredientService` +- [ ] Add `GetByLocationAsync` and `SetLocationUnitsAsync` to `IUnitService` and implement in `UnitService` +- [ ] Add `UnitService.DeleteAsync` guard: reject deletion if unit is referenced by any `RecipeIngredient` or `Ingredient` +- [ ] Add `UnitService.SetLocationUnitsAsync` guard: reject disabling a unit still in use at that location +- [ ] Create `IRecipeService` interface +- [ ] Refactor `RecipeService` to implement `IRecipeService` +- [ ] Add ingredient location scope guard to `RecipeService.CreateAsync` / `UpdateAsync` +- [ ] Add unit location scope guard to `RecipeService.CreateAsync` / `UpdateAsync` +- [ ] Implement `FlattenIngredientsAsync` with recursive algorithm +- [ ] Implement `WouldCreateCycleAsync` cycle detection +- [ ] Implement `DeductInventoryAsync` with unit conversion +- [ ] Implement `SyncRecipeSubRecipeTable` called on every save +- [ ] Implement `CostPerYield` calculation on save +- [ ] Register `IRecipeService` / `RecipeService` in `Program.cs` + +### Phase 3 β€” DTOs +- [ ] Create `RecipeSummaryDTO` +- [ ] Create `RecipeDetailDTO` +- [ ] Create `RecipeIngredientLineDTO` +- [ ] Create `RecipeStepDTO` +- [ ] Create `FlatIngredientLineDTO` +- [ ] Create `ProduceRecipeRequest` + +### Phase 4 β€” Blazor Components +- [ ] Extract `IngredientLineRow.razor` from `RecipeForm.razor` +- [ ] Update `IngredientLineRow` to accept `LocationId` as a `[Parameter]` +- [ ] Populate ingredient dropdown from `IIngredientService.GetByLocationAsync(LocationId)` +- [ ] Populate unit dropdown from `IUnitService.GetByLocationAsync(LocationId)` +- [ ] Add sub-recipe toggle to `IngredientLineRow` +- [ ] Create `SubRecipePicker.razor` +- [ ] Create `RecipeCostBadge.razor` +- [ ] Update `RecipeView.razor` β†’ replace with `RecipeDetailView.razor` that supports nested expansion +- [ ] Create `ProduceRecipeDialog.razor` with role gate +- [ ] Update `RecipeList.razor` to show `CostPerYield` and `IsSubRecipe` badge +- [ ] Create `CulinaryCommandApp/Inventory/Pages/Units/Index.razor` β€” unit management page with enable/disable toggle per location +- [ ] Create `CulinaryCommandApp/Inventory/Pages/Units/UnitForm.razor` β€” inline create/edit form for units +- [ ] Add "Units" nav link to `NavMenu.razor` under the Inventory section + +### Phase 5 β€” Testing +- [ ] Write `RecipeServiceFlattenTests` +- [ ] Write `RecipeServiceCycleDetectionTests` +- [ ] Write `RecipeServiceProduceTests` +- [ ] Write `RecipeServiceIngredientScopeTests` +- [ ] Write `RecipeServiceUnitScopeTests` +- [ ] Write `UnitServiceLocationTests` +- [ ] Write `UnitServiceDeleteGuardTests` +- [ ] Write `UnitServiceDisableGuardTests` +- [ ] Write `IngredientServiceLocationTests` +- [ ] Write `IngredientLineRowTests` +- [ ] Write `RecipeFormTests` +- [ ] All tests green + +--- + +*This document is the authoritative design reference for Recipe implementation in Culinary Command. All deviations must be discussed and reflected back into this document before code is merged.* From a09955f1631c3258bd4e5cbfff71bfdf1ab980d9 Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Fri, 27 Feb 2026 21:00:48 -0600 Subject: [PATCH 05/15] feat: finish IngredientLineRow logic + start recipe form --- .../Recipe/Pages/IngredientLineRow.razor | 13 +++++- .../Recipe/Pages/RecipeForm.razor | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeForm.razor diff --git a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor index 7006ae7..da68bf4 100644 --- a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor +++ b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor @@ -25,7 +25,7 @@ @foreach (var category in ingredientCategories) { @foreach ( var ingredient in IngredientsByCategory(category)){ - + } } @@ -54,9 +54,18 @@ @onchange="OnUnitChanged"> @foreach (var unit in availableUnits) { - + } + + + +
diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor b/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor new file mode 100644 index 0000000..42c648d --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor @@ -0,0 +1,43 @@ +@page "/recipes/new" +@page "/recipes/{RecipeId:int}/edit" +@using CulinaryCommandApp.Inventory.Entities +@rendermode InteractiveServer + + +@(RecipeId.HasValue ? "Edit Recipe" : "New Recipe") + +
+
+
+

@(RecipeId.HasValue ? "Edit Recipe" : "New Recipe")

+

@(RecipeId.HasValue ? "Update this recipe's details." : "Add a new recipe to your location.")

+
+
+ + + + + + +
+ + + +@code { + [Parameter] public int? RecipeId { get; set; } + + private Recipe recipe = new(); + private List ingredientLines = new(); + private List steps = new(); + private List yieldUnits = new(); + private List availableSubRecipes = new(); + private int locationId; + private bool isSaving; + + + private async Task HandleSubmit() { + + } + + +} \ No newline at end of file From 27698829fc3346df3eff6bb4b4bef227df6618fd Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Tue, 3 Mar 2026 01:13:29 -0600 Subject: [PATCH 06/15] feat: finish IngredientLineRow logic + start recipe form --- CulinaryCommandApp/Recipe/Entities/Recipe.cs | 4 ++ .../Recipe/Pages/IngredientLineRow.razor | 13 +----- .../Recipe/Pages/RecipeForm.razor | 43 ------------------- 3 files changed, 6 insertions(+), 54 deletions(-) delete mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeForm.razor diff --git a/CulinaryCommandApp/Recipe/Entities/Recipe.cs b/CulinaryCommandApp/Recipe/Entities/Recipe.cs index d85ec62..7ed845c 100644 --- a/CulinaryCommandApp/Recipe/Entities/Recipe.cs +++ b/CulinaryCommandApp/Recipe/Entities/Recipe.cs @@ -31,6 +31,10 @@ public class Recipe public DateTime? CreatedAt { get; set; } + // Required to handle concurrent changes + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); + // Navigation public ICollection RecipeIngredients { get; set; } = new List(); public ICollection Steps { get; set; } = new List(); diff --git a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor index da68bf4..7006ae7 100644 --- a/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor +++ b/CulinaryCommandApp/Recipe/Pages/IngredientLineRow.razor @@ -25,7 +25,7 @@ @foreach (var category in ingredientCategories) { @foreach ( var ingredient in IngredientsByCategory(category)){ - + } } @@ -54,18 +54,9 @@ @onchange="OnUnitChanged"> @foreach (var unit in availableUnits) { - + } - - - -
diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor b/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor deleted file mode 100644 index 42c648d..0000000 --- a/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor +++ /dev/null @@ -1,43 +0,0 @@ -@page "/recipes/new" -@page "/recipes/{RecipeId:int}/edit" -@using CulinaryCommandApp.Inventory.Entities -@rendermode InteractiveServer - - -@(RecipeId.HasValue ? "Edit Recipe" : "New Recipe") - -
-
-
-

@(RecipeId.HasValue ? "Edit Recipe" : "New Recipe")

-

@(RecipeId.HasValue ? "Update this recipe's details." : "Add a new recipe to your location.")

-
-
- - - - - - -
- - - -@code { - [Parameter] public int? RecipeId { get; set; } - - private Recipe recipe = new(); - private List ingredientLines = new(); - private List steps = new(); - private List yieldUnits = new(); - private List availableSubRecipes = new(); - private int locationId; - private bool isSaving; - - - private async Task HandleSubmit() { - - } - - -} \ No newline at end of file From 82d45a6fb12eb41ca7ef59697b1f090b5de8d02f Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Tue, 3 Mar 2026 01:13:51 -0600 Subject: [PATCH 07/15] delete unnecessary files --- docs/RecipeWorkflow.md | 1166 ---------------------------------------- 1 file changed, 1166 deletions(-) delete mode 100644 docs/RecipeWorkflow.md diff --git a/docs/RecipeWorkflow.md b/docs/RecipeWorkflow.md deleted file mode 100644 index b0c7f8e..0000000 --- a/docs/RecipeWorkflow.md +++ /dev/null @@ -1,1166 +0,0 @@ -# Recipe Workflow β€” Implementation Guide - -**Project:** Culinary Command -**Date:** February 26, 2026 -**Target Framework:** .NET 9.0 β€” Blazor Server (Interactive Server rendering) - ---- - -## Table of Contents - -- [Recipe Workflow β€” Implementation Guide](#recipe-workflow--implementation-guide) - - [Table of Contents](#table-of-contents) - - [1. Overview](#1-overview) - - [2. Current State Audit](#2-current-state-audit) - - [3. Design Principles \& Framework Alignment](#3-design-principles--framework-alignment) - - [Vertical Slice Architecture (Feature Folders)](#vertical-slice-architecture-feature-folders) - - [Interface-First Services](#interface-first-services) - - [Repository-Light (DbContext Directly in Services)](#repository-light-dbcontext-directly-in-services) - - [EF Core β€” Code-First Migrations](#ef-core--code-first-migrations) - - [Blazor Interactive Server Rendering](#blazor-interactive-server-rendering) - - [4. Domain Model \& Entity Design](#4-domain-model--entity-design) - - [4.1 Entity Descriptions](#41-entity-descriptions) - - [`Recipe`](#recipe) - - [`RecipeIngredient`](#recipeingredient) - - [`RecipeStep`](#recipestep) - - [`RecipeSubRecipe` *(new join/audit table β€” optional but recommended)*](#recipesubrecipe-new-joinaudit-table--optional-but-recommended) - - [4.2 Sub-Recipe (Nested Recipe) Pattern](#42-sub-recipe-nested-recipe-pattern) - - [Recursive Resolution Algorithm](#recursive-resolution-algorithm) - - [4.3 Key Design Decisions](#43-key-design-decisions) - - [5. ER Diagram](#5-er-diagram) - - [Reading the Diagram](#reading-the-diagram) - - [6. Database Schema Changes](#6-database-schema-changes) - - [6.1 Changes to Existing Tables](#61-changes-to-existing-tables) - - [`Recipe` β€” add two columns](#recipe--add-two-columns) - - [`RecipeIngredient` β€” make `IngredientId` nullable, add `SubRecipeId`](#recipeingredient--make-ingredientid-nullable-add-subrecipeid) - - [`RecipeStep` β€” increase `Instructions` to 2 048 characters](#recipestep--increase-instructions-to-2-048-characters) - - [6.2 New Table](#62-new-table) - - [`RecipeSubRecipe`](#recipesubrecipe) - - [6.3 Navigation Properties to Add to `Recipe`](#63-navigation-properties-to-add-to-recipe) - - [6.4 Generating the Migrations](#64-generating-the-migrations) - - [7. Location-Scoped Unit Management](#7-location-scoped-unit-management) - - [7.1 Unit Entity \& LocationUnit Join Table](#71-unit-entity--locationunit-join-table) - - [7.2 IUnitService Changes](#72-iunitservice-changes) - - [7.3 Blazor Unit Management UI](#73-blazor-unit-management-ui) - - [8. Inventory Catalog Integration](#8-inventory-catalog-integration) - - [8.1 Ingredient Source Constraint](#81-ingredient-source-constraint) - - [8.2 IIngredientService Changes](#82-iingredientservice-changes) - - [8.3 RecipeForm Ingredient Picker](#83-recipeform-ingredient-picker) - - [9. Service Layer](#9-service-layer) - - [9.1 `IRecipeService` Interface](#91-irecipeservice-interface) - - [9.2 Recursive Cost \& Ingredient Flattening](#92-recursive-cost--ingredient-flattening) - - [9.3 Circular Reference Guard](#93-circular-reference-guard) - - [10. Data Transfer Objects (DTOs)](#10-data-transfer-objects-dtos) - - [`RecipeSummaryDTO`](#recipesummarydto) - - [`RecipeDetailDTO`](#recipedetaildto) - - [`RecipeIngredientLineDTO`](#recipeingredientlinedto) - - [`RecipeStepDTO`](#recipestepdto) - - [`FlatIngredientLineDTO`](#flatingredientlinedto) - - [`ProduceRecipeRequest`](#producereciperequest) - - [11. Blazor Component Architecture](#11-blazor-component-architecture) - - [11.1 Page \& Component Breakdown](#111-page--component-breakdown) - - [Pages (thin route stubs)](#pages-thin-route-stubs) - - [Components (stateful, interactive)](#components-stateful-interactive) - - [Why extract `IngredientLineRow`?](#why-extract-ingredientlinerow) - - [11.2 Component Interaction Flow](#112-component-interaction-flow) - - [12. Inventory Integration](#12-inventory-integration) - - [13. Validation Strategy](#13-validation-strategy) - - [14. Migration Strategy](#14-migration-strategy) - - [15. Unit Testing Requirements](#15-unit-testing-requirements) - - [Service Tests (`CulinaryCommandUnitTests/Recipe/`)](#service-tests-culinarycommandunittestsrecipe) - - [Unit Management Service Tests (`CulinaryCommandUnitTests/Inventory/`)](#unit-management-service-tests-culinarycommandunittestsinventory) - - [Ingredient Service Tests (`CulinaryCommandUnitTests/Inventory/`)](#ingredient-service-tests-culinarycommandunittestsinventory) - - [Component Tests (`CulinaryCommandUnitTests/Recipe/`)](#component-tests-culinarycommandunittestsrecipe) - - [16. Implementation Checklist](#16-implementation-checklist) - - [Phase 1 β€” Schema \& Entities](#phase-1--schema--entities) - - [Phase 2 β€” Service Layer](#phase-2--service-layer) - - [Phase 3 β€” DTOs](#phase-3--dtos) - - [Phase 4 β€” Blazor Components](#phase-4--blazor-components) - - [Phase 5 β€” Testing](#phase-5--testing) - ---- - -## 1. Overview - -Recipes are a first-class domain object in Culinary Command. A recipe describes **what ingredients are needed**, **how much of each**, and **the procedure to prepare a dish**. Beyond single-level ingredient lists, professional kitchens rely heavily on **sub-recipes** β€” a preparatory item (e.g., "House Vinaigrette", "Beurre Blanc", "Brioche Dough") that is itself composed of raw ingredients and is then reused across multiple finished recipes. - -This document covers: - -- The full entity model required to support both flat and nested recipes. -- The reasoning behind every design decision. -- An ER diagram showing how every entity relates to the rest of the system. -- The service, DTO, and Blazor component architecture needed to implement it end-to-end. -- How recipe execution feeds back into the inventory system. - ---- - -## 2. Current State Audit - -The following recipe-related code already exists in the project and **must be preserved or extended**, not replaced. - -| Artifact | Location | Status | -|---|---|---| -| `Recipe` entity | `CulinaryCommandApp/Data/Entities/Recipe.cs` | Exists β€” needs `SubRecipes` navigation | -| `RecipeIngredient` entity | `CulinaryCommandApp/Data/Entities/RecipeIngredient.cs` | Exists β€” needs `SubRecipeId` FK column | -| `RecipeStep` entity | `CulinaryCommandApp/Data/Entities/RecipeStep.cs` | Exists β€” `Instructions` should grow to 2 048 chars | -| `RecipeService` | `CulinaryCommandApp/Services/RecipeService.cs` | Exists β€” must be extracted behind an interface | -| `RecipeForm.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeForm.razor` | Exists β€” needs sub-recipe picker row and location-scoped ingredient/unit pickers | -| `RecipeList.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeList.razor` | Exists β€” minor additions needed | -| `RecipeView.razor` | `CulinaryCommandApp/Components/Pages/Recipes/RecipeView.razor` | Exists β€” needs nested section | -| `Create.razor` / `Edit.razor` | `CulinaryCommandApp/Components/Pages/Recipes/` | Exist β€” minimal changes | -| `EnumService.GetRecipeTypes()` | `CulinaryCommandApp/Services/EnumService.cs` | Exists β€” `PrepItem` type already included | -| `AppDbContext` | `CulinaryCommandApp/Data/AppDbContext.cs` | Exists β€” needs `RecipeSubRecipe` and `LocationUnit` `DbSet`s | -| `Inventory.Entities.Unit` | `CulinaryCommandApp/Inventory/Entities/Unit.cs` | Exists β€” global (no `LocationId`); needs `LocationUnit` join table for per-location scoping | -| `Data.Entities.MeasurementUnit` | `CulinaryCommandApp/Data/Entities/MeasurementUnit.cs` | Exists β€” recipe-scoped unit stub; **superseded** by the location-scoped `Unit` approach described in Β§7 | -| `IUnitService` / `UnitService` | `CulinaryCommandApp/Inventory/Services/` | Exists β€” needs location-aware query methods | -| `IIngredientService` / `IngredientService` | `CulinaryCommandApp/Inventory/Services/` | Exists β€” needs `GetByLocationAsync` method | -| `InventoryManagementService` | `CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs` | Exists β€” `GetItemsByLocationAsync` already scopes by `LocationId`; used as the data source for the ingredient picker | -| `InventoryCatalog.razor` | `CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor` | Exists β€” the authoritative ingredient list; recipe form must source all ingredient choices from this same data set | - -**Gaps identified:** - -1. No `IRecipeService` interface β€” the concrete class is injected directly. -2. `RecipeIngredient` cannot reference a sub-recipe as an "ingredient line" β€” it only holds an `IngredientId`. -3. No cost-rollup or ingredient-flattening logic exists. -4. No circular-reference guard for nested recipes. -5. `RecipeStep.Instructions` is capped at 256 characters, which is insufficient for multi-sentence instructions. -6. `Inventory.Entities.Unit` has no `LocationId` β€” units are global and cannot be customised per restaurant location. A `LocationUnit` join table is required (mirroring the `LocationVendor` pattern) so owners can configure which units are available at their location. -7. `IIngredientService` has no location-scoped query method β€” `RecipeForm` currently has no way to restrict ingredient selection to the ingredients catalogued at the active location (`/inventory-catalog`). A `GetByLocationAsync` method and corresponding DTO are needed. - ---- - -## 3. Design Principles & Framework Alignment - -### Vertical Slice Architecture (Feature Folders) - -Culinary Command already organises its code in feature folders (`Inventory/`, `PurchaseOrder/`, `Vendor/`). Recipes should follow the same convention: - -``` -CulinaryCommandApp/ - Recipe/ - DTOs/ - Entities/ ← RecipeSubRecipe lives here - Mapping/ - Pages/ ← thin page stubs (routes only) - Components/ ← UI components - Services/ - Interfaces/ -``` - -All new types live under `CulinaryCommand.Recipe.*` namespaces. - -### Interface-First Services - -Every service in the Inventory module is backed by an interface (`IIngredientService`, `IUnitService`, etc.) and registered with DI. `RecipeService` must be refactored to implement `IRecipeService` for the same reasons β€” testability, replaceability, and adherence to the project's own pattern. - -### Repository-Light (DbContext Directly in Services) - -The project does not use a separate Repository layer β€” all services depend directly on `AppDbContext`. This pattern should be maintained. Introducing a separate repository layer would be inconsistent with existing code and would add unnecessary abstraction without a demonstrated benefit at this scale. - -### EF Core β€” Code-First Migrations - -All schema changes must be expressed as EF Core migrations using `dotnet ef migrations add`. No raw SQL DDL should be written manually. - -### Blazor Interactive Server Rendering - -All recipe pages use `@rendermode InteractiveServer`, consistent with `InventoryManagement.razor` and existing recipe pages. SignalR keeps the component tree in sync without API controllers. - ---- - -## 4. Domain Model & Entity Design - -### 4.1 Entity Descriptions - -#### `Recipe` - -The core aggregate root. Represents a named, versioned set of instructions and ingredients scoped to a `Location`. - -| Column | Type | Notes | -|---|---|---| -| `RecipeId` | `int` PK | Identity | -| `LocationId` | `int` FK | Scoped to a restaurant location | -| `Title` | `string(128)` | Display name | -| `Category` | `string(128)` | e.g., "Produce", "Dairy" (from `Category` enum) | -| `RecipeType` | `string(128)` | e.g., "Entree", "Sauce", "Prep Item" (from `RecipeType` enum) | -| `YieldAmount` | `decimal?` | How much this recipe produces | -| `YieldUnit` | `string(128)` | Unit of the yield (e.g., "Liters", "Each") | -| `IsSubRecipe` | `bool` | `true` when this recipe is intended to be embedded in other recipes | -| `CostPerYield` | `decimal?` | Cached/computed total food cost per yield unit | -| `CreatedAt` | `datetime` | UTC timestamp | - -**Why add `IsSubRecipe`?** It is a query-time optimisation. When the `RecipeForm` presents a sub-recipe picker, it needs to filter the recipe list to only show items that make sense as building blocks. Scanning `RecipeType == "Prep Item"` would work but is fragile β€” a sauce or stock could also be a sub-recipe. A dedicated boolean is unambiguous and queryable with a simple index. - -**Why add `CostPerYield`?** Food cost is the primary business metric for any restaurant. Caching the rolled-up cost on the `Recipe` row avoids recursive database traversals on every page load. It is recomputed whenever a recipe is saved. - ---- - -#### `RecipeIngredient` - -A single line on a recipe β€” **either** a raw inventory ingredient **or** a sub-recipe. Exactly one of `IngredientId` and `SubRecipeId` must be non-null (enforced at the service layer and by a check constraint). - -| Column | Type | Notes | -|---|---|---| -| `RecipeIngredientId` | `int` PK | Identity | -| `RecipeId` | `int` FK | Parent recipe | -| `IngredientId` | `int?` FK | References `Inventory.Ingredients.Id` β€” null when line is a sub-recipe | -| `SubRecipeId` | `int?` FK | References `Recipe.RecipeId` β€” null when line is a raw ingredient | -| `UnitId` | `int` FK | References `Inventory.Units.Id` | -| `Quantity` | `decimal` | Amount of the ingredient or sub-recipe yield used | -| `PrepNote` | `string(256)` | e.g., "finely diced", "room temperature" | -| `SortOrder` | `int` | Display order within the recipe | - -**Why allow `SubRecipeId` on `RecipeIngredient` rather than a separate join table?** This is the most widely adopted pattern (used by ChefTec, MarginEdge, and Recipe.ly) because it keeps the ingredient-line concept cohesive. A recipe line is always a quantity of something β€” whether that something is a raw item or a finished prep. Using a separate join table would require two different loops in every consumer of the recipe, complicating both service and UI code. The nullable pair `(IngredientId | SubRecipeId)` with a check constraint clearly communicates the exclusivity invariant. - ---- - -#### `RecipeStep` - -An ordered instruction step for preparing the recipe. The `Instructions` column should be expanded from 256 to 2 048 characters to accommodate real-world step descriptions. - -| Column | Type | Notes | -|---|---|---| -| `StepId` | `int` PK | Identity | -| `RecipeId` | `int` FK | Parent recipe | -| `StepNumber` | `int` | 1-based ordinal | -| `Instructions` | `string(2048)` | Full preparation instruction | - ---- - -#### `RecipeSubRecipe` *(new join/audit table β€” optional but recommended)* - -When a sub-recipe is used as a line in a parent recipe, the relationship is already captured by `RecipeIngredient.SubRecipeId`. However, maintaining a direct `RecipeSubRecipe` join table between the two `Recipe` rows provides several benefits: - -- Enables a fast "where is this sub-recipe used?" reverse lookup without scanning `RecipeIngredient`. -- Makes circular-reference detection via a simple SQL query feasible. -- Mirrors how the `UserLocation` and `ManagerLocation` join tables handle M:M relationships in the existing schema. - -| Column | Type | Notes | -|---|---|---| -| `ParentRecipeId` | `int` FK | The recipe that includes the sub-recipe | -| `ChildRecipeId` | `int` FK | The sub-recipe being embedded | - -Composite PK: `(ParentRecipeId, ChildRecipeId)`. - -This table is **derived** from `RecipeIngredient` rows β€” it is populated automatically by `RecipeService` whenever a recipe is saved. It should never be written to directly by UI code. - ---- - -### 4.2 Sub-Recipe (Nested Recipe) Pattern - -A sub-recipe is a `Recipe` where `IsSubRecipe = true`. It is referenced on a parent recipe's ingredient list via `RecipeIngredient.SubRecipeId`. The depth of nesting is unbounded by the schema β€” a sub-recipe can itself contain another sub-recipe. - -**Example hierarchy:** - -``` -Caesar Salad (Entree) -β”œβ”€β”€ 2 heads Romaine Lettuce [raw ingredient] -β”œβ”€β”€ 0.5 cup Croutons [raw ingredient] -└── 1 oz Caesar Dressing [sub-recipe β†’ Prep Item] - β”œβ”€β”€ 0.25 cup Olive Oil [raw ingredient] - β”œβ”€β”€ 2 cloves Garlic [raw ingredient] - └── 1 tsp Anchovy Paste [raw ingredient] -``` - -When "Caesar Salad" is prepared (i.e., "produced"): - -1. Stock of Romaine Lettuce decreases by 2 heads. -2. Stock of Croutons decreases by 0.5 cup. -3. The service **recursively resolves** Caesar Dressing and deducts its constituent ingredients proportionally based on the quantity of dressing used. - -#### Recursive Resolution Algorithm - -``` -FlattenIngredients(recipeId, multiplier, visited): - if recipeId ∈ visited β†’ throw CircularReferenceException - add recipeId to visited - - for each line in RecipeIngredients where RecipeId = recipeId: - if line.IngredientId is not null: - yield (IngredientId, Quantity Γ— multiplier, UnitId) - else: - subYield = SubRecipe.YieldAmount ?? 1 - ratio = line.Quantity / subYield - yield from FlattenIngredients(line.SubRecipeId, multiplier Γ— ratio, visited) -``` - -The `visited` set prevents infinite loops when a recipe accidentally references itself through an indirect chain. This guard must be applied at save time (to reject the configuration) **and** at resolve time (as a defence-in-depth measure). - ---- - -### 4.3 Key Design Decisions - -| Decision | Rationale | -|---|---| -| Sub-recipe FK lives on `RecipeIngredient`, not a separate table | Keeps the "ingredient line" concept cohesive; one loop for consumers | -| `IsSubRecipe` boolean on `Recipe` | Efficient UI filtering; explicit intent over convention | -| `CostPerYield` cached on `Recipe` | Avoids recursive DB traversal on every render; recomputed on save | -| `RecipeSubRecipe` mirror table | Fast reverse lookup ("used in") and circular reference detection via SQL | -| Interface `IRecipeService` | Matches project pattern; enables unit testing without a DB | -| No separate Repository layer | Consistent with existing `IngredientService`, `UnitService`, etc. | -| `RecipeType` stays string constant (not DB enum) | Matches existing `Category` and `RecipeType` constant classes; avoids migrations for new types | -| `Location`-scoped recipes | Consistent with Inventory β€” every recipe belongs to one `Location`; a `Company` can have multiple locations with different menus | -| `LocationUnit` join table for per-location unit configuration | Mirrors `LocationVendor`; allows each restaurant to curate the units available in their recipe forms without altering the global `Unit` catalogue | -| Ingredient picker sourced exclusively from `/inventory-catalog` (location-scoped) | Guarantees that every ingredient on a recipe exists in the location's live inventory, enabling accurate stock deduction and cost calculation | -| `MeasurementUnit` entity superseded by location-scoped `Unit` usage | `Data.Entities.MeasurementUnit` was an incomplete stub; the authoritative unit entity is `Inventory.Entities.Unit`, now scoped via `LocationUnit` | - ---- - -## 5. ER Diagram - -```mermaid -erDiagram - direction TB - - Company { - int Id PK - string Name - string CompanyCode - } - - Location { - int Id PK - int CompanyId FK - string Name - string Address - } - - User { - int Id PK - int CompanyId FK - string Name - string Role - } - - Recipe { - int RecipeId PK - int LocationId FK - string Title - string Category - string RecipeType - decimal YieldAmount - string YieldUnit - bool IsSubRecipe - decimal CostPerYield - datetime CreatedAt - } - - RecipeIngredient { - int RecipeIngredientId PK - int RecipeId FK - int IngredientId FK "nullable" - int SubRecipeId FK "nullable" - int UnitId FK - decimal Quantity - string PrepNote - int SortOrder - } - - RecipeStep { - int StepId PK - int RecipeId FK - int StepNumber - string Instructions - } - - RecipeSubRecipe { - int ParentRecipeId PK,FK - int ChildRecipeId PK,FK - } - - Ingredient { - int Id PK - int LocationId FK - int UnitId FK - int VendorId FK "nullable" - string Name - string Category - decimal StockQuantity - decimal ReorderLevel - decimal Price - } - - Unit { - int Id PK - string Name - string Abbreviation - decimal ConversionFactor - } - - LocationUnit { - int LocationId PK,FK - int UnitId PK,FK - datetime AssignedAt - } - - InventoryTransaction { - int Id PK - int IngredientId FK - int UnitId FK - decimal StockChange - string Reason - datetime CreatedAt - } - - Vendor { - int Id PK - int CompanyId FK - string Name - } - - Company ||--o{ Location : "has" - Company ||--o{ User : "employs" - Location ||--o{ Recipe : "owns" - Location ||--o{ LocationUnit : "configures" - Unit ||--o{ LocationUnit : "available at" - Recipe ||--o{ RecipeIngredient : "contains" - Recipe ||--o{ RecipeStep : "has" - Recipe ||--o{ RecipeSubRecipe : "parent of" - Recipe ||--o{ RecipeSubRecipe : "child of" - RecipeIngredient }o--o| Ingredient : "uses raw" - RecipeIngredient }o--o| Recipe : "uses sub-recipe" - RecipeIngredient ||--|| Unit : "measured in" - Ingredient ||--o{ InventoryTransaction : "tracked by" - Ingredient ||--|| Unit : "base unit" - Ingredient }o--o| Vendor : "supplied by" - Location ||--o{ Ingredient : "stocks" -``` - -### Reading the Diagram - -- **`Company β†’ Location β†’ Recipe`** β€” The ownership chain. Every recipe belongs to exactly one location, which belongs to exactly one company. This scopes the recipe book correctly for multi-tenant deployments. -- **`Location β†’ LocationUnit ← Unit`** β€” Each location configures its own set of allowed units (e.g., a bakery might enable "grams" and "cups" but not "fluid ounces"). This mirrors the `LocationVendor` pattern already used in the Vendor module. -- **`Recipe β†’ RecipeIngredient`** β€” One-to-many; a recipe has zero or more ingredient lines. -- **`RecipeIngredient β†’ Ingredient`** (nullable) β€” When a line references a raw ingredient, this FK is populated. The ingredient **must** belong to the same `LocationId` as the parent recipe β€” enforced at the service layer and pre-filtered in the UI picker. -- **`RecipeIngredient β†’ Unit`** β€” The unit used on this line must be one that is enabled for the location via `LocationUnit`. -- **`RecipeIngredient β†’ Recipe`** (nullable, `SubRecipeId`) β€” When a line references a sub-recipe, this FK is populated instead. The same `RecipeIngredient` entity handles both cases, which is the discriminated-union pattern. -- **`Recipe β†’ RecipeSubRecipe ← Recipe`** β€” The mirror join table sits between two `Recipe` rows, recording which recipes embed which. The double `Recipe` arrow reflects the self-referential many-to-many. -- **`Ingredient β†’ InventoryTransaction`** β€” When a recipe is produced, the service flattens all raw ingredients and creates one `InventoryTransaction` per unique ingredient, using the existing `InventoryTransactionService`. - ---- - -## 6. Database Schema Changes - -### 6.1 Changes to Existing Tables - -#### `Recipe` β€” add two columns - -```csharp -public bool IsSubRecipe { get; set; } = false; -public decimal? CostPerYield { get; set; } -``` - -#### `RecipeIngredient` β€” make `IngredientId` nullable, add `SubRecipeId` - -```csharp -public int? IngredientId { get; set; } -public int? SubRecipeId { get; set; } -public Recipe? SubRecipe { get; set; } -``` - -The existing `IngredientId` column is currently non-nullable (it has no `?`). Making it nullable is a **breaking schema change** β€” a migration is required and existing rows must be verified to be unaffected. - -#### `RecipeStep` β€” increase `Instructions` to 2 048 characters - -```csharp -[MaxLength(2048)] -public string? Instructions { get; set; } -``` - -### 6.2 New Table - -#### `RecipeSubRecipe` - -```csharp -namespace CulinaryCommand.Recipe.Entities -{ - public class RecipeSubRecipe - { - public int ParentRecipeId { get; set; } - public Recipe? ParentRecipe { get; set; } - - public int ChildRecipeId { get; set; } - public Recipe? ChildRecipe { get; set; } - } -} -``` - -Registered in `AppDbContext`: - -```csharp -public DbSet RecipeSubRecipes => Set(); -``` - -Fluent configuration in `OnModelCreating`: - -```csharp -modelBuilder.Entity() - .HasKey(rs => new { rs.ParentRecipeId, rs.ChildRecipeId }); - -modelBuilder.Entity() - .HasOne(rs => rs.ParentRecipe) - .WithMany(r => r.SubRecipeUsages) - .HasForeignKey(rs => rs.ParentRecipeId) - .OnDelete(DeleteBehavior.Cascade); - -modelBuilder.Entity() - .HasOne(rs => rs.ChildRecipe) - .WithMany(r => r.UsedInRecipes) - .HasForeignKey(rs => rs.ChildRecipeId) - .OnDelete(DeleteBehavior.Restrict); -``` - -`DeleteBehavior.Restrict` on the child side prevents accidentally orphaning sub-recipe references when the child recipe is deleted. The service layer should check for usages before allowing deletion of a sub-recipe. - -### 6.3 Navigation Properties to Add to `Recipe` - -```csharp -public ICollection SubRecipeUsages { get; set; } = new List(); -public ICollection UsedInRecipes { get; set; } = new List(); -``` - -### 6.4 Generating the Migrations - -```bash -dotnet ef migrations add AddSubRecipeSupport \ - --project CulinaryCommandApp \ - --startup-project CulinaryCommandApp -``` - -> See Β§14 (Migration Strategy) for the full ordered sequence, which now includes the `LocationUnit` table and the retirement of `MeasurementUnit`. - ---- - -## 7. Location-Scoped Unit Management - -### 7.1 Unit Entity & LocationUnit Join Table - -`Inventory.Entities.Unit` is currently a **global** catalogue β€” every location sees every unit. Restaurant owners need to configure a curated subset of units for their location (e.g., a bakery enables grams and cups; a bar enables millilitres and fluid ounces). This is modelled with a `LocationUnit` join table, exactly mirroring how `LocationVendor` works in the Vendor module. - -Create `CulinaryCommandApp/Inventory/Entities/LocationUnit.cs`: - -```csharp -using System.Text.Json.Serialization; -using CulinaryCommand.Data.Entities; - -namespace CulinaryCommand.Inventory.Entities -{ - /// - /// Join table linking a Unit to a specific Location. - /// Tracks which units of measurement are enabled for a given restaurant location. - /// - public class LocationUnit - { - public int LocationId { get; set; } - - [JsonIgnore] - public Location Location { get; set; } = default!; - - public int UnitId { get; set; } - - [JsonIgnore] - public Unit Unit { get; set; } = default!; - - public DateTime AssignedAt { get; set; } = DateTime.UtcNow; - } -} -``` - -Register in `AppDbContext`: - -```csharp -public DbSet LocationUnits => Set(); -``` - -Fluent configuration in `OnModelCreating`: - -```csharp -modelBuilder.Entity() - .HasKey(lu => new { lu.LocationId, lu.UnitId }); - -modelBuilder.Entity() - .HasOne(lu => lu.Location) - .WithMany(l => l.LocationUnits) - .HasForeignKey(lu => lu.LocationId); - -modelBuilder.Entity() - .HasOne(lu => lu.Unit) - .WithMany(u => u.LocationUnits) - .HasForeignKey(lu => lu.UnitId); -``` - -Add the corresponding navigation properties: - -- `Location` entity: `public ICollection LocationUnits { get; set; } = new List();` -- `Unit` entity: `public ICollection LocationUnits { get; set; } = new List();` - -**Why not add `LocationId` directly to `Unit`?** A unit like "grams" is not owned by one location β€” it is a shared reference. The join table lets multiple locations enable the same unit independently, and lets each location disable units they never use, keeping their dropdowns clean. - -**`MeasurementUnit` retirement:** `Data.Entities.MeasurementUnit` was an incomplete stub that duplicated `Inventory.Entities.Unit` without location scoping. Now that `Unit` is location-scoped via `LocationUnit`, `MeasurementUnit` should be marked obsolete and removed in a follow-up cleanup migration. Until that migration runs, `AppDbContext.MeasurementUnits` can remain registered but should not be referenced by any new code. - ---- - -### 7.2 IUnitService Changes - -Add two location-scoped methods to `IUnitService` (`CulinaryCommandApp/Inventory/Services/Interfaces/IUnitService.cs`): - -```csharp -/// Returns only the units enabled for a specific location. -Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default); - -/// Sets the complete list of units enabled at a location, adding/removing as needed. -Task SetLocationUnitsAsync(int locationId, IEnumerable unitIds, CancellationToken cancellationToken = default); -``` - -`UnitService` implements these by querying `_db.LocationUnits` β€” the same pattern used by `VendorService.GetVendorsByLocationAsync` and `VendorService.SetLocationVendorsAsync`. - ---- - -### 7.3 Blazor Unit Management UI - -Create a unit-configuration page under the Inventory feature folder, modelled after the existing Vendor management pages: - -| File | Route | Purpose | -|---|---|---| -| `CulinaryCommandApp/Inventory/Pages/Units/Index.razor` | `/units` | List all global units; shows which are enabled for the current location | -| `CulinaryCommandApp/Inventory/Pages/Units/UnitForm.razor` | (component) | Inline form for creating/editing a unit (name, abbreviation, conversion factor) | - -The `Index.razor` page renders a two-column layout: -- **Left:** all available units in the global catalogue with an enable/disable toggle per location. -- **Right:** a form to create a new unit (which is immediately added to the global catalogue; the owner can then enable it for their location). - -Add a "Units" link to `NavMenu.razor` under the Inventory section, consistent with the existing "Vendors" link pattern. - -**Role gating:** Only managers and admins (the same `priv` flag check used on recipe and vendor pages) may create, edit, or assign units. Read-only employees see the enabled units list but cannot modify it. - ---- - -## 8. Inventory Catalog Integration - -### 8.1 Ingredient Source Constraint - -When building a recipe, the ingredient picker in `IngredientLineRow` must show **only** the ingredients that are catalogued at the current location β€” i.e., the exact same data set that powers `/inventory-catalog`. This enforces a hard invariant: every ingredient on a recipe has a known stock record at the location, which makes cost calculation and inventory deduction reliable. - -`Inventory.Entities.Ingredient` already has a `LocationId` FK. `InventoryManagementService.GetItemsByLocationAsync(locationId)` already filters on this column and is the existing data source for the catalog page. The recipe form must use this same method (or a thin wrapper) rather than the unscoped `IIngredientService.GetAllAsync`. - -**Why not allow ingredients from other locations?** A recipe belongs to one location. If it could reference an ingredient from a different location, the service would have no valid stock row to deduct from when the recipe is produced. Cross-location sharing is a future concern and should be designed explicitly when needed. - ---- - -### 8.2 IIngredientService Changes - -Add one location-scoped method to `IIngredientService` (`CulinaryCommandApp/Inventory/Services/Interfaces/IIngredientService.cs`): - -```csharp -/// -/// Returns all ingredients belonging to a specific location, -/// matching the data shown on the /inventory-catalog page. -/// -Task> GetByLocationAsync(int locationId, CancellationToken cancellationToken = default); -``` - -Implement in `IngredientService`: - -```csharp -public async Task> GetByLocationAsync( - int locationId, CancellationToken cancellationToken = default) -{ - return await _db.Ingredients - .AsNoTracking() - .Include(i => i.Unit) - .Where(i => i.LocationId == locationId) - .OrderBy(i => i.Category) - .ThenBy(i => i.Name) - .ToListAsync(cancellationToken); -} -``` - -This is a direct analogue to `InventoryManagementService.GetItemsByLocationAsync` but returns the raw `Ingredient` entity (with its `Unit` navigation loaded) rather than a display DTO, which is what the recipe service needs for cost calculation. - ---- - -### 8.3 RecipeForm Ingredient Picker - -`IngredientLineRow.razor` currently populates `AvailableIngredients` from an unscoped call. This must be updated: - -1. `IngredientLineRow` receives the current `LocationId` as a `[Parameter]`. -2. On initialisation (or when switching to "raw ingredient" mode), it calls `IIngredientService.GetByLocationAsync(LocationId)`. -3. The ingredient dropdown is populated from this location-scoped list β€” identical to the `/inventory-catalog` catalogue. -4. The unit dropdown for the line is populated from `IUnitService.GetByLocationAsync(LocationId)` β€” the location's configured units. - -Similarly, the unit dropdown on `RecipeForm` for the recipe's own `YieldUnit` field must be populated from the location's configured units rather than the global `Unit` table. - -**Cascaded category filter (optional enhancement):** The ingredient picker may optionally offer a category pre-filter (mirroring the category filter on `/inventory-catalog`) to help users find ingredients quickly. Since `Ingredient.Category` is already populated in the data, this requires no schema change β€” just a client-side `@bind` filter on the dropdown list. - ---- - -## 9. Service Layer - -### 9.1 `IRecipeService` Interface - -Create `CulinaryCommandApp/Recipe/Services/Interfaces/IRecipeService.cs`: - -```csharp -using CulinaryCommand.Data.Entities; -using CulinaryCommand.Recipe.DTOs; - -namespace CulinaryCommand.Recipe.Services.Interfaces -{ - public interface IRecipeService - { - Task> GetAllByLocationAsync(int locationId, CancellationToken ct = default); - Task GetDetailAsync(int recipeId, CancellationToken ct = default); - Task> GetSubRecipesForLocationAsync(int locationId, CancellationToken ct = default); - Task CreateAsync(Data.Entities.Recipe recipe, CancellationToken ct = default); - Task UpdateAsync(Data.Entities.Recipe recipe, CancellationToken ct = default); - Task DeleteAsync(int recipeId, CancellationToken ct = default); - Task> FlattenIngredientsAsync(int recipeId, decimal multiplier = 1m, CancellationToken ct = default); - Task DeductInventoryAsync(int recipeId, decimal servingsProduced, CancellationToken ct = default); - } -} -``` - -**Why `RecipeSummaryDTO` and `RecipeDetailDTO`?** Returning the raw EF entity from a service is acceptable in this project (as seen in `RecipeService.GetAllByLocationIdAsync`) but the detail view needs richer data (ingredient names, sub-recipe titles, nested cost breakdowns) that would require multiple `Include` chains. Projecting into DTOs at the service layer avoids lazy-loading pitfalls and keeps Blazor components free of database concerns. - ---- - -### 9.2 Recursive Cost & Ingredient Flattening - -Create `CulinaryCommandApp/Recipe/Services/RecipeService.cs`. Key method: - -```csharp -public async Task> FlattenIngredientsAsync( - int recipeId, decimal multiplier = 1m, CancellationToken ct = default) -{ - return await FlattenCoreAsync(recipeId, multiplier, new HashSet(), ct); -} - -private async Task> FlattenCoreAsync( - int recipeId, decimal multiplier, HashSet visited, CancellationToken ct) -{ - if (!visited.Add(recipeId)) - throw new InvalidOperationException( - $"Circular sub-recipe reference detected at RecipeId {recipeId}."); - - var lines = await _db.RecipeIngredients - .AsNoTracking() - .Include(ri => ri.Ingredient) - .Include(ri => ri.Unit) - .Include(ri => ri.SubRecipe) - .Where(ri => ri.RecipeId == recipeId) - .ToListAsync(ct); - - var result = new List(); - - foreach (var line in lines) - { - if (line.IngredientId.HasValue) - { - result.Add(new FlatIngredientLineDTO - { - IngredientId = line.IngredientId.Value, - IngredientName = line.Ingredient!.Name, - Quantity = line.Quantity * multiplier, - UnitId = line.UnitId, - UnitName = line.Unit?.Name ?? string.Empty - }); - } - else if (line.SubRecipeId.HasValue) - { - var subYield = line.SubRecipe?.YieldAmount ?? 1m; - var ratio = line.Quantity / subYield; - var nested = await FlattenCoreAsync( - line.SubRecipeId.Value, multiplier * ratio, visited, ct); - result.AddRange(nested); - } - } - - visited.Remove(recipeId); - return result; -} -``` - -**Why remove `recipeId` from `visited` after processing?** The visited set guards against *cycles*, not against a sub-recipe being reused in multiple unrelated branches of the same recipe (diamond dependency). Removing the ID after a branch completes allows legitimate multi-use without false positives. - ---- - -### 9.3 Circular Reference Guard - -A guard must also run at **save time**, not just at flatten time. Before persisting any `RecipeIngredient` with a `SubRecipeId`, the service must verify the candidate sub-recipe does not (directly or transitively) reference the parent recipe. - -```csharp -private async Task WouldCreateCycleAsync( - int parentRecipeId, int proposedChildId, CancellationToken ct) -{ - var visited = new HashSet { parentRecipeId }; - var queue = new Queue(); - queue.Enqueue(proposedChildId); - - while (queue.Count > 0) - { - var current = queue.Dequeue(); - if (!visited.Add(current)) - return true; - - var children = await _db.RecipeSubRecipes - .Where(rs => rs.ParentRecipeId == current) - .Select(rs => rs.ChildRecipeId) - .ToListAsync(ct); - - foreach (var child in children) - queue.Enqueue(child); - } - - return false; -} -``` - -This BFS traversal leverages the `RecipeSubRecipe` mirror table for efficient graph traversal, avoiding a full `RecipeIngredient` scan. - ---- - -## 10. Data Transfer Objects (DTOs) - -Create under `CulinaryCommandApp/Recipe/DTOs/`: - -### `RecipeSummaryDTO` - -Used in list views β€” lightweight, no nested data. - -```csharp -public class RecipeSummaryDTO -{ - public int RecipeId { get; set; } - public string Title { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string RecipeType { get; set; } = string.Empty; - public decimal? YieldAmount { get; set; } - public string YieldUnit { get; set; } = string.Empty; - public bool IsSubRecipe { get; set; } - public decimal? CostPerYield { get; set; } -} -``` - -### `RecipeDetailDTO` - -Used in the detail/view page β€” includes flattened ingredient lines and steps. - -```csharp -public class RecipeDetailDTO -{ - public int RecipeId { get; set; } - public string Title { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string RecipeType { get; set; } = string.Empty; - public decimal? YieldAmount { get; set; } - public string YieldUnit { get; set; } = string.Empty; - public bool IsSubRecipe { get; set; } - public decimal? CostPerYield { get; set; } - public List Ingredients { get; set; } = new(); - public List Steps { get; set; } = new(); - public List UsedInRecipes { get; set; } = new(); -} -``` - -### `RecipeIngredientLineDTO` - -Represents one line in the recipe, abstracting over raw vs. sub-recipe. - -```csharp -public class RecipeIngredientLineDTO -{ - public int RecipeIngredientId { get; set; } - public int SortOrder { get; set; } - public decimal Quantity { get; set; } - public string UnitName { get; set; } = string.Empty; - public int UnitId { get; set; } - public string PrepNote { get; set; } = string.Empty; - - public bool IsSubRecipe => SubRecipeId.HasValue; - - public int? IngredientId { get; set; } - public string? IngredientName { get; set; } - - public int? SubRecipeId { get; set; } - public string? SubRecipeTitle { get; set; } -} -``` - -### `RecipeStepDTO` - -```csharp -public class RecipeStepDTO -{ - public int StepId { get; set; } - public int StepNumber { get; set; } - public string Instructions { get; set; } = string.Empty; -} -``` - -### `FlatIngredientLineDTO` - -Used internally by the service for cost rollup and inventory deduction β€” never sent to the UI directly. - -```csharp -public class FlatIngredientLineDTO -{ - public int IngredientId { get; set; } - public string IngredientName { get; set; } = string.Empty; - public decimal Quantity { get; set; } - public int UnitId { get; set; } - public string UnitName { get; set; } = string.Empty; -} -``` - -### `ProduceRecipeRequest` - -Triggers inventory deduction when a recipe is produced/executed. - -```csharp -public class ProduceRecipeRequest -{ - public int RecipeId { get; set; } - public decimal ServingsProduced { get; set; } = 1m; - public string? Notes { get; set; } -} -``` - ---- - -## 11. Blazor Component Architecture - -### 11.1 Page & Component Breakdown - -All files live under `CulinaryCommandApp/Components/Pages/Recipes/` (pages) and a new `CulinaryCommandApp/Recipe/Components/` folder (reusable components). - -#### Pages (thin route stubs) - -| File | Route | Purpose | -|---|---|---| -| `Index.razor` | `/recipes` | Hosts `RecipeList` | -| `Create.razor` | `/recipes/create` | Hosts `RecipeForm` in create mode | -| `Edit.razor` | `/recipes/edit/{id:int}` | Hosts `RecipeForm` in edit mode | -| `RecipeView.razor` | `/recipes/view/{id:int}` | Hosts `RecipeDetailView` | - -#### Components (stateful, interactive) - -| Component | Location | Responsibility | -|---|---|---| -| `RecipeList.razor` | `Components/Pages/Recipes/` | Table of recipes for current location; search, filter, navigate | -| `RecipeForm.razor` | `Components/Pages/Recipes/` | Create/edit recipe; ingredient lines; sub-recipe picker; step editor | -| `RecipeDetailView.razor` | `Recipe/Components/` | Read-only recipe display with nested sub-recipe expansion | -| `IngredientLineRow.razor` | `Recipe/Components/` | A single ingredient line within `RecipeForm` β€” handles toggle between raw and sub-recipe mode | -| `SubRecipePicker.razor` | `Recipe/Components/` | Dropdown/search to select a sub-recipe from the same location | -| `RecipeCostBadge.razor` | `Recipe/Components/` | Inline display of `CostPerYield` with currency formatting | -| `ProduceRecipeDialog.razor` | `Recipe/Components/` | Modal that accepts servings count and triggers inventory deduction | - -#### Why extract `IngredientLineRow`? - -The current `RecipeForm.razor` renders all ingredient lines inline in a `@foreach` loop. Adding sub-recipe support introduces a toggle (raw vs. sub-recipe), conditional dropdowns, and nested display logic. Extracting each line to its own component keeps `RecipeForm` readable, allows the row to manage its own state (selected category, loaded ingredients list, sub-recipe mode), and makes it independently testable using `bUnit`. - ---- - -### 11.2 Component Interaction Flow - -``` -RecipeForm [LocationId param] - β”œβ”€β”€ [n Γ— IngredientLineRow] [LocationId param] - β”‚ β”œβ”€β”€ mode: "raw" β†’ CategoryFilter (client-side) β†’ IngredientSelect - β”‚ β”‚ (IIngredientService.GetByLocationAsync(LocationId)) - β”‚ β”‚ β†’ UnitSelect - β”‚ β”‚ (IUnitService.GetByLocationAsync(LocationId)) - β”‚ └── mode: "sub-recipe" β†’ SubRecipePicker - β”‚ (IRecipeService.GetSubRecipesForLocationAsync(LocationId)) - β”œβ”€β”€ YieldUnit select (IUnitService.GetByLocationAsync(LocationId)) - β”œβ”€β”€ [n Γ— StepEditor] (inline textarea rows, no extraction needed yet) - └── OnValidSubmit ──────────→ IRecipeService.CreateAsync / UpdateAsync - └── ValidateIngredientsBelongToLocation() - └── ValidateUnitsEnabledForLocation() - └── ValidateNoCycle() - └── RecalculateCost() β†’ updates CostPerYield - └── SyncRecipeSubRecipeTable() - -RecipeDetailView - β”œβ”€β”€ RecipeCostBadge - β”œβ”€β”€ [n Γ— IngredientLineRow (read-only)] - β”‚ └── if IsSubRecipe β†’ nested RecipeDetailView (collapsible) - └── ProduceRecipeDialog (manager/admin only) - └── IRecipeService.DeductInventoryAsync() - └── FlattenIngredients() β†’ IInventoryTransactionService.RecordAsync() Γ— N -``` - -**Why is `ProduceRecipeDialog` on the detail view?** Producing a recipe is an intentional, high-consequence action (it modifies stock). Placing it behind a confirmation modal on the detail view (rather than the list) ensures the user has reviewed the recipe before committing inventory changes. Role gating (`priv` flag, as used in `RecipeList.razor`) should restrict the "Produce" button to managers and admins. - ---- - -## 12. Inventory Integration - -When a recipe is "produced" (`DeductInventoryAsync`): - -1. Call `FlattenIngredientsAsync(recipeId, servingsProduced)` to get the complete list of raw ingredients and quantities. -2. Group lines by `IngredientId` and sum quantities (a sub-recipe's ingredient may appear in multiple branches). -3. For each grouped ingredient, call `IInventoryTransactionService.RecordAsync` with a negative `StockChange` and `Reason = $"Recipe production: {recipe.Title}"`. -4. All `RecordAsync` calls must succeed or all must roll back β€” wrap in a `using var tx = await _db.Database.BeginTransactionAsync()` block inside the service. This mirrors the existing transaction pattern in `InventoryTransactionService.RecordAsync`. - -**Why use the existing `InventoryTransactionService`?** It already contains the conditional update SQL (`UPDATE Ingredients SET StockQuantity = StockQuantity + {delta} WHERE ...`) that prevents stock going negative in a concurrent environment. Reusing it avoids duplicating this critical concurrency logic. - -**Unit conversion note:** `RecipeIngredient.UnitId` references `Inventory.Units` which carries a `ConversionFactor`. When deducting stock, the service must convert the recipe quantity from the recipe's measurement unit to the ingredient's base stock unit before deducting. Example: recipe specifies "500 ml" cream, but the ingredient is tracked in "liters" β€” deduct 0.5 L. - ---- - -## 13. Validation Strategy - -All validation is enforced in two places: the service layer (always) and the Blazor form (for user feedback). - -| Rule | Where enforced | -|---|---| -| `Recipe.Title` required, max 128 chars | Data annotation + service guard | -| `Recipe.Category` must be a valid `Category` constant | Service guard | -| `Recipe.RecipeType` must be a valid `RecipeType` constant | Service guard | -| Each `RecipeIngredient` line: exactly one of `IngredientId` / `SubRecipeId` non-null | Service guard + Blazor toggle logic | -| Each `RecipeIngredient` line: `Quantity > 0` | Data annotation | -| `Recipe.YieldAmount > 0` when `YieldUnit` is set | Service guard | -| Sub-recipe reference must not create a circular dependency | `WouldCreateCycleAsync()` in service | -| Sub-recipe must belong to the same `LocationId` as the parent recipe | Service guard | -| Cannot delete a recipe that is referenced as a sub-recipe by another recipe | Service `DeleteAsync` pre-check | -| `RecipeIngredient.IngredientId` must reference an ingredient belonging to the same `LocationId` as the recipe | Service guard β€” queries `Ingredient.LocationId == recipe.LocationId` before save | -| `RecipeIngredient.UnitId` must reference a unit enabled for the recipe's location via `LocationUnit` | Service guard β€” queries `LocationUnits` before save | -| `Recipe.YieldUnit` (when stored as a `UnitId`) must be a unit enabled for the location | Service guard | -| Cannot delete a `Unit` that is still referenced by any `RecipeIngredient` or `Ingredient` at that location | `UnitService.DeleteAsync` pre-check | -| Cannot disable (remove from `LocationUnit`) a unit that is still used on an active ingredient or recipe line at that location | Service guard in `SetLocationUnitsAsync` | - -**Blazor form validation** should use `` with `` and ``. For the sub-recipe cycle check, since it requires a DB round-trip, it should be surfaced as a non-field error message rendered in a `
` block after the `Save` button is clicked. - ---- - -## 14. Migration Strategy - -Migrations must be applied in order. The recommended sequence is: - -1. **`AddIsSubRecipeAndCostPerYield`** β€” adds `IsSubRecipe` (bit, default 0) and `CostPerYield` (decimal, nullable) to `Recipes`. -2. **`MakeRecipeIngredientIngredientIdNullable`** β€” alters `IngredientId` to nullable and adds `SubRecipeId` (int, nullable, FK to `Recipes(RecipeId)`). -3. **`AddRecipeSubRecipeTable`** β€” creates the `RecipeSubRecipes` join table with composite PK and both FKs. -4. **`ExpandRecipeStepInstructions`** β€” increases `Instructions` from `nvarchar(256)` to `nvarchar(2048)`. -5. **`AddLocationUnitTable`** β€” creates the `LocationUnits` join table (`LocationId`, `UnitId`, `AssignedAt`) with composite PK and FKs to `Locations` and `Units`. Seed: for each existing `Location`, insert a `LocationUnit` row for every `Unit` currently in the database so no existing location loses access to units after the migration. -6. **`DeprecateMeasurementUnit`** *(deferred cleanup)* β€” drops the `MeasurementUnits` table and removes the corresponding `DbSet` registration. This migration should only run after confirming no production rows exist in `MeasurementUnits`. - -Each migration should be reviewed in its generated `.cs` file before applying. Verify that step 2 does not drop and recreate the existing `RecipeIngredient` table β€” check the `MigrationBuilder` output carefully. For step 5, review the seed logic to ensure all existing locations receive sensible default units. - ---- - -## 15. Unit Testing Requirements - -Tests live in `CulinaryCommandUnitTests/`. The project already uses `bUnit` for Blazor component tests. - -### Service Tests (`CulinaryCommandUnitTests/Recipe/`) - -| Test Class | Covers | -|---|---| -| `RecipeServiceFlattenTests` | Single-level, two-level nested, diamond dependency, circular reference detection | -| `RecipeServiceCostTests` | Cost rollup correctness for flat and nested recipes | -| `RecipeServiceCycleDetectionTests` | `WouldCreateCycleAsync` returns true for direct and indirect cycles | -| `RecipeServiceProduceTests` | `DeductInventoryAsync` calls `IInventoryTransactionService.RecordAsync` with correct quantities; rollback on insufficient stock | -| `RecipeServiceIngredientScopeTests` | `CreateAsync` / `UpdateAsync` reject ingredients whose `LocationId` does not match the recipe's location | -| `RecipeServiceUnitScopeTests` | `CreateAsync` / `UpdateAsync` reject units not present in `LocationUnits` for the recipe's location | - -### Unit Management Service Tests (`CulinaryCommandUnitTests/Inventory/`) - -| Test Class | Covers | -|---|---| -| `UnitServiceLocationTests` | `GetByLocationAsync` returns only units with a matching `LocationUnit` row; `SetLocationUnitsAsync` adds and removes rows correctly | -| `UnitServiceDeleteGuardTests` | `DeleteAsync` throws when unit is referenced by an active `RecipeIngredient` or `Ingredient` | -| `UnitServiceDisableGuardTests` | `SetLocationUnitsAsync` throws when attempting to remove a unit still in use at that location | - -### Ingredient Service Tests (`CulinaryCommandUnitTests/Inventory/`) - -| Test Class | Covers | -|---|---| -| `IngredientServiceLocationTests` | `GetByLocationAsync` returns only ingredients with matching `LocationId`; ordering by category then name | - -### Component Tests (`CulinaryCommandUnitTests/Recipe/`) - -| Test Class | Covers | -|---|---| -| `IngredientLineRowTests` | Toggle between raw and sub-recipe mode; ingredient dropdown populated from location-scoped list; unit dropdown populated from location-configured units | -| `RecipeFormTests` | Form submits valid data; shows error when cycle detected; ingredient and unit dropdowns are empty when no location context provided | -| `RecipeDetailViewTests` | Nested sub-recipe section renders; "Produce" button hidden for non-managers | - -All service tests should use an in-memory EF Core database (`UseInMemoryDatabase`) or Moq for `AppDbContext` to avoid a live DB dependency. - ---- - -## 16. Implementation Checklist - -Use this checklist to track progress. Complete items in order β€” later steps depend on earlier ones. - -### Phase 1 β€” Schema & Entities -- [x] Add `IsSubRecipe` and `CostPerYield` to `Recipe` entity -- [ ] Make `RecipeIngredient.IngredientId` nullable; add `SubRecipeId` and `SubRecipe` navigation -- [ ] Expand `RecipeStep.Instructions` to `MaxLength(2048)` -- [ ] Create `RecipeSubRecipe` entity under `CulinaryCommandApp/Recipe/Entities/` -- [ ] Add `SubRecipeUsages` and `UsedInRecipes` navigation to `Recipe` -- [ ] Register `RecipeSubRecipes` `DbSet` in `AppDbContext` -- [ ] Add Fluent API configuration for `RecipeSubRecipe` in `OnModelCreating` -- [ ] Create `LocationUnit` entity under `CulinaryCommandApp/Inventory/Entities/` -- [ ] Add `LocationUnits` navigation to `Location` entity -- [ ] Add `LocationUnits` navigation to `Unit` entity -- [ ] Register `LocationUnits` `DbSet` in `AppDbContext` -- [ ] Add Fluent API configuration for `LocationUnit` in `OnModelCreating` -- [ ] Generate and review all six migrations (including `AddLocationUnitTable` and deferred `DeprecateMeasurementUnit`) -- [ ] Apply migrations to development database; verify seed populates `LocationUnits` for existing locations - -### Phase 2 β€” Service Layer -- [ ] Add `GetByLocationAsync` to `IIngredientService` and implement in `IngredientService` -- [ ] Add `GetByLocationAsync` and `SetLocationUnitsAsync` to `IUnitService` and implement in `UnitService` -- [ ] Add `UnitService.DeleteAsync` guard: reject deletion if unit is referenced by any `RecipeIngredient` or `Ingredient` -- [ ] Add `UnitService.SetLocationUnitsAsync` guard: reject disabling a unit still in use at that location -- [ ] Create `IRecipeService` interface -- [ ] Refactor `RecipeService` to implement `IRecipeService` -- [ ] Add ingredient location scope guard to `RecipeService.CreateAsync` / `UpdateAsync` -- [ ] Add unit location scope guard to `RecipeService.CreateAsync` / `UpdateAsync` -- [ ] Implement `FlattenIngredientsAsync` with recursive algorithm -- [ ] Implement `WouldCreateCycleAsync` cycle detection -- [ ] Implement `DeductInventoryAsync` with unit conversion -- [ ] Implement `SyncRecipeSubRecipeTable` called on every save -- [ ] Implement `CostPerYield` calculation on save -- [ ] Register `IRecipeService` / `RecipeService` in `Program.cs` - -### Phase 3 β€” DTOs -- [ ] Create `RecipeSummaryDTO` -- [ ] Create `RecipeDetailDTO` -- [ ] Create `RecipeIngredientLineDTO` -- [ ] Create `RecipeStepDTO` -- [ ] Create `FlatIngredientLineDTO` -- [ ] Create `ProduceRecipeRequest` - -### Phase 4 β€” Blazor Components -- [ ] Extract `IngredientLineRow.razor` from `RecipeForm.razor` -- [ ] Update `IngredientLineRow` to accept `LocationId` as a `[Parameter]` -- [ ] Populate ingredient dropdown from `IIngredientService.GetByLocationAsync(LocationId)` -- [ ] Populate unit dropdown from `IUnitService.GetByLocationAsync(LocationId)` -- [ ] Add sub-recipe toggle to `IngredientLineRow` -- [ ] Create `SubRecipePicker.razor` -- [ ] Create `RecipeCostBadge.razor` -- [ ] Update `RecipeView.razor` β†’ replace with `RecipeDetailView.razor` that supports nested expansion -- [ ] Create `ProduceRecipeDialog.razor` with role gate -- [ ] Update `RecipeList.razor` to show `CostPerYield` and `IsSubRecipe` badge -- [ ] Create `CulinaryCommandApp/Inventory/Pages/Units/Index.razor` β€” unit management page with enable/disable toggle per location -- [ ] Create `CulinaryCommandApp/Inventory/Pages/Units/UnitForm.razor` β€” inline create/edit form for units -- [ ] Add "Units" nav link to `NavMenu.razor` under the Inventory section - -### Phase 5 β€” Testing -- [ ] Write `RecipeServiceFlattenTests` -- [ ] Write `RecipeServiceCycleDetectionTests` -- [ ] Write `RecipeServiceProduceTests` -- [ ] Write `RecipeServiceIngredientScopeTests` -- [ ] Write `RecipeServiceUnitScopeTests` -- [ ] Write `UnitServiceLocationTests` -- [ ] Write `UnitServiceDeleteGuardTests` -- [ ] Write `UnitServiceDisableGuardTests` -- [ ] Write `IngredientServiceLocationTests` -- [ ] Write `IngredientLineRowTests` -- [ ] Write `RecipeFormTests` -- [ ] All tests green - ---- - -*This document is the authoritative design reference for Recipe implementation in Culinary Command. All deviations must be discussed and reflected back into this document before code is merged.* From 1435448cdc3d7cc099ee6de0181e3c10ea75b21c Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Thu, 5 Mar 2026 19:40:46 -0600 Subject: [PATCH 08/15] feat: Add the ability to create a recipe --- .../LocationSettings/ConfigureLocation.razor | 152 +++ .../Pages/Inventory/InventoryCatalog.razor | 197 ++- ...0305191150_AddRecipeRowVersion.Designer.cs | 1126 +++++++++++++++++ .../20260305191150_AddRecipeRowVersion.cs | 31 + .../Migrations/AppDbContextModelSnapshot.cs | 5 + CulinaryCommandApp/Program.cs | 2 + .../Components/ProduceRecipeDialog.razor | 255 ++++ .../Recipe/Pages/IngredientLineRow.razor | 2 +- .../Recipe/Pages/RecipeCreate.razor | 83 ++ .../Recipe/Pages/RecipeEdit.razor | 102 ++ .../Recipe/Pages/RecipeForm.razor | 693 ++++++++++ .../Recipe/Pages/RecipeList.razor | 485 ++++++- .../Recipe/Pages/RecipeView.razor | 256 ++++ .../Recipe/Pages/RecipeView.razor.css | 321 +++++ .../Recipe/Pages/_Imports.razor | 2 + .../Services/Interfaces/IRecipeService.cs | 34 + .../Recipe/Services/RecipeService.cs | 67 +- 17 files changed, 3737 insertions(+), 76 deletions(-) create mode 100644 CulinaryCommandApp/Migrations/20260305191150_AddRecipeRowVersion.Designer.cs create mode 100644 CulinaryCommandApp/Migrations/20260305191150_AddRecipeRowVersion.cs create mode 100644 CulinaryCommandApp/Recipe/Components/ProduceRecipeDialog.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeCreate.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeForm.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeView.razor create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css create mode 100644 CulinaryCommandApp/Recipe/Services/Interfaces/IRecipeService.cs diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor b/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor index dd09708..7cea837 100644 --- a/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor +++ b/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor @@ -5,6 +5,9 @@ @using CulinaryCommand.Vendor.Services @using CulinaryCommand.Services @using CulinaryCommand.Services.UserContextSpace +@using CulinaryCommandApp.Inventory.Entities +@using CulinaryCommandApp.Inventory.Services +@using CulinaryCommandApp.Inventory.Services.Interfaces @using Microsoft.AspNetCore.Authorization @using System.Net.Http.Json @using System.Text.Json @@ -12,6 +15,7 @@ @inject IVendorService VendorService @inject ILocationService LocationService +@inject IUnitService UnitService @inject IUserContextService UserCtx @inject NavigationManager Nav @inject IConfiguration Config @@ -132,6 +136,61 @@
}
+ + +
+
+
Measurement Units
+ +
+ +
+ @if (!_locationUnits.Any()) + { +
+ + No units assigned to this location yet. Add one to get started. +
+ } + else + { + + + + + + + + + + @foreach (var unit in _locationUnits) + { + + + + + + } + +
NameAbbreviation
@unit.Name@unit.Abbreviation + +
+ } +
+ + @if (_locationUnits.Any()) + { + + } +
} @@ -261,15 +320,66 @@ } + +@if (_showAddUnitModal) +{ + +} + @code { [Parameter] public int LocationId { get; set; } private Location? _location; private List _locationVendors = new(); + private List _locationUnits = new(); + private List _availableUnitsToAdd = new(); private bool _hydrated; private bool _showAddVendorModal; private bool _creatingVendor; + private bool _showAddUnitModal; + private bool _addingUnit; + private int _selectedUnitId; private Vendor _newVendor = new(); private int? _companyId; @@ -313,6 +423,7 @@ if (_location is not null && _companyId.HasValue) { _locationVendors = await VendorService.GetVendorsByLocationAsync(LocationId); + _locationUnits = await UnitService.GetByLocationAsync(LocationId); } _hydrated = true; @@ -445,4 +556,45 @@ _showAddVendorModal = false; StateHasChanged(); } + + // ── Units ───────────────────────────────────────────────────────────────── + + private async Task OpenAddUnitModal() + { + var all = await UnitService.GetAllAsync(); + var assignedIds = _locationUnits.Select(u => u.Id).ToHashSet(); + _availableUnitsToAdd = all.Where(u => !assignedIds.Contains(u.Id)).ToList(); + _selectedUnitId = 0; + _showAddUnitModal = true; + } + + private void CloseAddUnitModal() + { + _showAddUnitModal = false; + _selectedUnitId = 0; + } + + private async Task AddUnit() + { + if (_selectedUnitId == 0) return; + + _addingUnit = true; + + var currentIds = _locationUnits.Select(u => u.Id).ToList(); + currentIds.Add(_selectedUnitId); + await UnitService.SetLocationUnitsAsync(LocationId, currentIds); + _locationUnits = await UnitService.GetByLocationAsync(LocationId); + + _addingUnit = false; + _showAddUnitModal = false; + StateHasChanged(); + } + + private async Task RemoveUnit(int unitId) + { + var remaining = _locationUnits.Select(u => u.Id).Where(id => id != unitId); + await UnitService.SetLocationUnitsAsync(LocationId, remaining); + _locationUnits = await UnitService.GetByLocationAsync(LocationId); + StateHasChanged(); + } } diff --git a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor index 7dc51b4..bdc8021 100644 --- a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor +++ b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor @@ -1,16 +1,36 @@ @page "/inventory-catalog" @rendermode InteractiveServer +@implements IDisposable @using CulinaryCommandApp.Inventory.DTOs +@using CulinaryCommandApp.Inventory.Entities +@using CulinaryCommandApp.Inventory.Services +@using CulinaryCommandApp.Inventory.Services.Interfaces +@using CulinaryCommand.Services + +@inject IInventoryManagementService InventoryService +@inject IUnitService UnitService +@inject LocationState LocationState +@inject EnumService EnumService
-

Inventory Catalog - testing deployment change

-

Manage your ingredient catalog

+

Inventory Catalog

+

@(LocationState.CurrentLocation is not null ? $"{LocationState.CurrentLocation.Name} β€” ingredient catalog" : "Manage your ingredient catalog")

+ @if (LocationState.CurrentLocation is null) + { + + } + else + { +
+ + } @* end @if (LocationState.CurrentLocation is not null) *@ +
@@ -152,7 +175,7 @@