diff --git a/CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.Designer.cs b/CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.Designer.cs new file mode 100644 index 0000000..ff73ead --- /dev/null +++ b/CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.Designer.cs @@ -0,0 +1,1518 @@ +// +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("20260414000001_AddRecipeImageAndMetadata")] + partial class AddRecipeImageAndMetadata + { + /// + 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.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Device") + .HasColumnType("longtext"); + + b.Property("FeedbackType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Message") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Page") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ScreenshotBase64") + .HasColumnType("LONGTEXT"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserEmail") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("UserRole") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Feedbacks"); + }); + + 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.TaskList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("IsActive") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("LocationId"); + + b.ToTable("TaskLists"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.TaskListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListId") + .HasColumnType("int"); + + b.Property("TaskTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TaskTemplateId"); + + b.HasIndex("TaskListId", "TaskTemplateId") + .IsUnique(); + + b.ToTable("TaskListItems"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.TaskTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("DefaultEstimatedMinutes") + .HasColumnType("int"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit(1)"); + + 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("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("LocationId"); + + b.HasIndex("RecipeId"); + + b.ToTable("TaskTemplates"); + }); + + 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.DTOs.InventoryItemDTO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CurrentQuantity") + .HasColumnType("decimal(65,30)"); + + b.Property("IsLowStock") + .HasColumnType("bit(1)"); + + b.Property("LastOrderDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OutOfStockDate") + .HasColumnType("datetime(6)"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.Property("ReorderLevel") + .HasColumnType("decimal(65,30)"); + + b.Property("SKU") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StorageLocationId") + .HasColumnType("int"); + + b.Property("StorageLocationName") + .HasColumnType("longtext"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorLogoUrl") + .HasColumnType("longtext"); + + b.Property("VendorName") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("StorageLocationId"); + + b.ToTable("InventoryItemDTO"); + }); + + 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("StorageLocationId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("StorageLocationId"); + + 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.StorageLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("StorageLocations"); + }); + + 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("Allergens") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CostPerYield") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageData") + .HasColumnType("longtext"); + + b.Property("IsSubRecipe") + .HasColumnType("bit(1)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PortionSize") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("RecipeType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + 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.Feedback", b => + { + b.HasOne("CulinaryCommand.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.TaskList", b => + { + b.HasOne("CulinaryCommand.Data.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.HasOne("CulinaryCommand.Data.Entities.Location", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.TaskListItem", b => + { + b.HasOne("CulinaryCommand.Data.Entities.TaskList", "TaskList") + .WithMany("Items") + .HasForeignKey("TaskListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CulinaryCommand.Data.Entities.TaskTemplate", "TaskTemplate") + .WithMany("TaskListItems") + .HasForeignKey("TaskTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TaskList"); + + b.Navigation("TaskTemplate"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.TaskTemplate", b => + { + b.HasOne("CulinaryCommand.Data.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + 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"); + + b.Navigation("CreatedByUser"); + + b.Navigation("Ingredient"); + + b.Navigation("Location"); + + b.Navigation("Recipe"); + }); + + 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.DTOs.InventoryItemDTO", b => + { + b.HasOne("CulinaryCommandApp.Inventory.Entities.StorageLocation", null) + .WithMany("InventoryItems") + .HasForeignKey("StorageLocationId"); + }); + + 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.StorageLocation", "StorageLocation") + .WithMany() + .HasForeignKey("StorageLocationId"); + + 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("StorageLocation"); + + 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.TaskList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("CulinaryCommand.Data.Entities.TaskTemplate", b => + { + b.Navigation("TaskListItems"); + }); + + 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.StorageLocation", b => + { + b.Navigation("InventoryItems"); + }); + + 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/20260414000001_AddRecipeImageAndMetadata.cs b/CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.cs new file mode 100644 index 0000000..9670a27 --- /dev/null +++ b/CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CulinaryCommand.Migrations +{ + /// + public partial class AddRecipeImageAndMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Allergens", + table: "Recipes", + type: "varchar(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "ImageData", + table: "Recipes", + type: "longtext", + nullable: true); + + migrationBuilder.AddColumn( + name: "PortionSize", + table: "Recipes", + type: "varchar(128)", + maxLength: 128, + nullable: true); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Allergens", + table: "Recipes"); + + migrationBuilder.DropColumn( + name: "ImageData", + table: "Recipes"); + + migrationBuilder.DropColumn( + name: "PortionSize", + table: "Recipes"); + + } + } +} diff --git a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs index f5d4b13..9748249 100644 --- a/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs +++ b/CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs @@ -900,6 +900,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecipeId")); + b.Property("Allergens") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + b.Property("Category") .IsRequired() .HasMaxLength(128) @@ -911,12 +915,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("datetime(6)"); + b.Property("ImageData") + .HasColumnType("longtext"); + b.Property("IsSubRecipe") .HasColumnType("bit(1)"); b.Property("LocationId") .HasColumnType("int"); + b.Property("PortionSize") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + b.Property("RecipeType") .IsRequired() .HasMaxLength(128) diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index 2c9c932..5d69361 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -103,6 +103,11 @@ options.TokenValidationParameters.NameClaimType = "cognito:username"; options.TokenValidationParameters.RoleClaimType = "cognito:groups"; + + // Allow correlation/nonce cookies over plain HTTP in development + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Events.OnRedirectToIdentityProvider = ctx => { // Forces correct scheme/host behind nginx @@ -119,7 +124,12 @@ // ===================== // AI Services // ===================== -builder.Services.AddSingleton(_ => new Client()); +builder.Services.AddSingleton(sp => +{ + var apiKey = builder.Configuration["Google:ApiKey"] + ?? throw new InvalidOperationException("Google:ApiKey is not configured."); + return new Client(apiKey: apiKey); +}); builder.Services.AddScoped(); // diff --git a/CulinaryCommandApp/Recipe/Entities/Recipe.cs b/CulinaryCommandApp/Recipe/Entities/Recipe.cs index b64b838..729a49b 100644 --- a/CulinaryCommandApp/Recipe/Entities/Recipe.cs +++ b/CulinaryCommandApp/Recipe/Entities/Recipe.cs @@ -29,6 +29,15 @@ public class Recipe public bool IsSubRecipe { get; set; } = false; + [MaxLength(128)] + public string? PortionSize { get; set; } + + [MaxLength(256)] + public string? Allergens { get; set; } + + // Base64-encoded image (no MaxLength → LONGTEXT in MySQL) + public string? ImageData { get; set; } + public DateTime? CreatedAt { get; set; } // Optimistic concurrency token — backed by a MySQL timestamp(6) column diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor b/CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor index afc67be..2b0593a 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor +++ b/CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor @@ -39,14 +39,11 @@ else if (_notFound) else {
- + + @* ---- Section: Recipe Image ---- *@ +
Recipe Image
+ +
+
+ @if (!string.IsNullOrWhiteSpace(Model.ImageData)) + { +
+ Recipe preview + +
+ } + else + { +
+ +

No image uploaded

+

JPG, PNG or WEBP · max 5 MB

+
+ } +
+ + @if (!string.IsNullOrWhiteSpace(_imageError)) + { +
@_imageError
+ } +
+ +
+ + @* ---- Section: Serving Info ---- *@ +
Serving & Nutrition
+ +
+
+ + +
+
+ + @* Allergens *@ +
+ + +
Comma-separate multiple allergens.
+
+ +
+ @* ---- Section 2: Ingredient Lines ----*@
Ingredients
@@ -401,6 +460,9 @@ else { private bool _dataLoading; private readonly SemaphoreSlim _loadSemaphore = new(1, 1); + // Image upload + private string? _imageError; + // Reference data private List _recipeTypes = new(); private List _recipeCategories = new(); @@ -766,6 +828,45 @@ else { private void Cancel() => Nav.NavigateTo("/recipes"); + // *** Image upload *** + + private async Task HandleImageUpload(InputFileChangeEventArgs e) + { + _imageError = null; + var file = e.File; + + const long maxBytes = 5 * 1024 * 1024; // 5 MB + if (file.Size > maxBytes) + { + _imageError = "Image must be under 5 MB."; + return; + } + + if (!file.ContentType.StartsWith("image/")) + { + _imageError = "Please select a valid image file."; + return; + } + + try + { + using var ms = new System.IO.MemoryStream(); + await using var stream = file.OpenReadStream(maxBytes); + await stream.CopyToAsync(ms); + var base64 = Convert.ToBase64String(ms.ToArray()); + Model.ImageData = $"data:{file.ContentType};base64,{base64}"; + } + catch + { + _imageError = "Failed to read the image. Please try again."; + } + } + + private void RemoveImage() + { + Model.ImageData = null; + } + // *** View model for ingredient lines *** private sealed class IngredientLineViewModel { diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor.css b/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor.css new file mode 100644 index 0000000..ba9c796 --- /dev/null +++ b/CulinaryCommandApp/Recipe/Pages/RecipeForm.razor.css @@ -0,0 +1,108 @@ +/* ── Recipe image upload card ─────────────────────────────────────────────── */ + +.rf-image-card { + width: 100%; + max-width: 280px; + border-radius: 12px; + border: 2px dashed #d1d5db; + overflow: hidden; + background: #f9fafb; + transition: border-color 0.2s; +} + +.rf-image-card:hover { + border-color: #9ca3af; +} + +.rf-image-placeholder { + padding: 28px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + color: #9ca3af; + text-align: center; +} + +.rf-image-placeholder i { + font-size: 2.2rem; +} + +.rf-image-placeholder p { + margin: 0; + font-size: 0.88rem; + font-weight: 500; + color: #6b7280; +} + +.rf-upload-hint { + font-size: 0.76rem !important; + color: #9ca3af !important; +} + +.rf-image-preview { + position: relative; +} + +.rf-preview-img { + width: 100%; + max-height: 240px; + object-fit: cover; + display: block; +} + +.rf-remove-img { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(17, 24, 39, 0.65); + color: #ffffff; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s; +} + +.rf-remove-img:hover { + background: rgba(220, 38, 38, 0.85); +} + +/* ── Dietary tag chip grid ───────────────────────────────────────────────── */ + +.rf-tag-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.rf-tag-chip { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: 999px; + border: 1.5px solid #d1d5db; + background: #ffffff; + color: #374151; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.rf-tag-chip:hover { + border-color: #0a8f3c; + color: #0a8f3c; +} + +.rf-tag-chip.checked { + background: #dcfce7; + border-color: #0a8f3c; + color: #166534; +} diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor index edcddb7..cca1fbc 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor @@ -8,216 +8,266 @@ @inject IUserContextService UserCtx @inject NavigationManager Nav - - @(_model?.Title ?? "Recipe") -@if (!_ready) -{ -
-
- Loading… +
+ @if (!_ready) + { +
+
+ Loading… +
-
-} -else if (_notFound) -{ -
-
+ } + else if (_notFound) + { +
Recipe not found. It may have been deleted. Back to Recipes
-
-} -else -{ - var recipe = _model!; - -
- - @* ── Header card ── *@ -
-
-
-
-

@recipe.Title

- @if (recipe.IsSubRecipe) - { - - Sub-Recipe - - } -
-
- @if (!string.IsNullOrWhiteSpace(recipe.Category)) - { - @recipe.Category - } - @if (!string.IsNullOrWhiteSpace(recipe.RecipeType)) - { - @recipe.RecipeType - } - @if (recipe.CreatedAt.HasValue) - { - - - @recipe.CreatedAt.Value.ToString("MMM d, yyyy") - - } + } + else + { + var recipe = _model!; + + @* ── Back button ── *@ + + + @* ── Two-column layout ── *@ +
+ + @* ════ LEFT PANEL ════ *@ +
+ -
- - Back - - @if (_priv) + @* Allergens *@ + @if (!string.IsNullOrWhiteSpace(recipe.Allergens)) { - - Edit - - +
+ +
+ + Contains: @recipe.Allergens +
+
} -
-
-
- @* ── Ingredients ── *@ -
-
- Ingredients - @recipe.RecipeIngredients.Count -
+ - @if (!recipe.RecipeIngredients.Any()) - { -

No ingredients added.

- } - else - { - - - - - - - - - - - - @foreach (var line in recipe.RecipeIngredients.OrderBy(l => l.SortOrder)) - { - - - - - - - - } - -
#Ingredient / Sub-RecipeQuantityUnitPrep Note
@line.SortOrder - @if (line.SubRecipeId.HasValue) - { - - - @(line.SubRecipe?.Title ?? $"Sub-Recipe #{line.SubRecipeId}") - - } - else - { - @(line.Ingredient?.Name ?? "—") - } - @line.Quantity.ToString("G29")@(line.Unit?.Abbreviation ?? "—")@(line.PrepNote ?? "—")
- } -
- - @* ── Steps ── *@ -
-
- Preparation Steps - @recipe.Steps.Count -
+ @* ════ RIGHT PANEL ════ *@ +
- @if (!recipe.Steps.Any()) - { -

No preparation steps added.

- } - else - { -
    - @foreach (var step in recipe.Steps.OrderBy(s => s.StepNumber)) + @* ── Ingredients ── *@ +
    +
    + Ingredients + @if (recipe.YieldAmount.HasValue && recipe.YieldAmount.Value > 0) { -
  1. -
    -

    @step.Instructions

    +
    + + +
    + } +
    - @if (!string.IsNullOrWhiteSpace(step.Duration) - || !string.IsNullOrWhiteSpace(step.Temperature) - || !string.IsNullOrWhiteSpace(step.Equipment)) + @if (!recipe.RecipeIngredients.Any()) + { +

    No ingredients added.

    + } + else + { +
    + + + + + + + + + + + + @foreach (var line in recipe.RecipeIngredients.OrderBy(l => l.SortOrder)) { -
    - @if (!string.IsNullOrWhiteSpace(step.Duration)) - { - - @step.Duration - - } - @if (!string.IsNullOrWhiteSpace(step.Temperature)) - { - - @step.Temperature - - } - @if (!string.IsNullOrWhiteSpace(step.Equipment)) - { - - @step.Equipment - - } -
    + + + + + + + } + +
    QuantityIngredientPrep NotesSupplierInv
    + @ScaledQty(line.Quantity, recipe.YieldAmount) + @if (line.Unit is not null) + { + @line.Unit.Abbreviation + } + + @if (line.SubRecipeId.HasValue) + { + + + @(line.SubRecipe?.Title ?? $"Sub-Recipe #{line.SubRecipeId}") + + } + else + { + @(line.Ingredient?.Name ?? "—") + } + + @(string.IsNullOrWhiteSpace(line.PrepNote) ? "—" : line.PrepNote) + + @(line.Ingredient?.Vendor?.Name ?? "—") + + @if (line.IngredientId.HasValue) + { + + + + } +
    +
    + } +
  2. + + @* ── Steps ── *@ +
    +
    + Step-by-Step Instructions +
    + + @if (!recipe.Steps.Any()) + { +

    No preparation steps provided.

    + } + else + { +
    + @foreach (var step in recipe.Steps.OrderBy(s => s.StepNumber)) + { +
    + @step.StepNumber +
    +

    @step.Instructions

    + @if (!string.IsNullOrWhiteSpace(step.Duration) + || !string.IsNullOrWhiteSpace(step.Temperature) + || !string.IsNullOrWhiteSpace(step.Equipment)) + { +
    + @if (!string.IsNullOrWhiteSpace(step.Duration)) + { + + @step.Duration + + } + @if (!string.IsNullOrWhiteSpace(step.Temperature)) + { + + @step.Temperature + + } + @if (!string.IsNullOrWhiteSpace(step.Equipment)) + { + + @step.Equipment + + } +
    + } +
    - - } -
- } -
+ } +
+ } +
+
-
-} + } +
@code { @@ -227,7 +277,8 @@ else private bool _ready; private bool _notFound; private bool _priv; - private ProduceRecipeDialog? _produceDialog; + private string? _sourceRestaurant; + private decimal _scaleServings = 1; protected override async Task OnInitializedAsync() { @@ -252,6 +303,39 @@ else _model = await RecipeService.GetByIdAsync(Id, allowedLocationIds); _notFound = _model is null; + if (_model is not null) + { + _sourceRestaurant = ctx.AccessibleLocations + .FirstOrDefault(l => l.Id == _model.LocationId)?.Name; + + // Default scale = original yield + _scaleServings = _model.YieldAmount ?? 1; + } + _ready = true; } + + // ── Scale helpers ───────────────────────────────────────────────────────── + + private record ScaleOption(decimal Value, string Label); + + private List GetScaleOptions(decimal baseYield, string? unit) + { + var opts = new List(); + decimal[] multipliers = { 0.5m, 1m, 2m, 3m, 4m, 6m, 8m, 10m, 12m }; + var suffix = string.IsNullOrWhiteSpace(unit) ? "servings" : unit; + foreach (var m in multipliers) + { + var servings = baseYield * m; + opts.Add(new ScaleOption(servings, $"{servings.ToString("G29")} {suffix}")); + } + return opts; + } + + private string ScaledQty(decimal originalQty, decimal? baseYield) + { + if (!baseYield.HasValue || baseYield.Value == 0) return originalQty.ToString("G29"); + var scaled = originalQty / baseYield.Value * _scaleServings; + return scaled.ToString("G29"); + } } diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css index 6837e9b..41c600a 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css @@ -1,311 +1,519 @@ -/* ── Layout ─────────────────────────────────────────────────────────────── */ +/* ── Page wrapper ────────────────────────────────────────────────────────── */ -.rv-container { - max-width: 900px; +.rv-page { + max-width: 1200px; margin: 0 auto; - padding: 24px 24px 48px; - font-family: "Source Sans 3", "Segoe UI", sans-serif; - color: var(--cc-ink); - background: var(--cc-bg); + padding: 20px 20px 48px; + background: #f6f7f9; min-height: 100vh; + font-family: "Source Sans 3", "Segoe UI", sans-serif; + color: #1f2a37; } -/* ── Header card ─────────────────────────────────────────────────────────── */ +/* ── Back button row ─────────────────────────────────────────────────────── */ -.rv-header-card { - background: var(--cc-card); - border-radius: 16px; - border: 1px solid var(--cc-border); - box-shadow: var(--cc-shadow); - padding: 24px 28px; - margin-bottom: 20px; +.rv-back-row { display: flex; - flex-direction: column; - gap: 16px; + align-items: center; + margin-bottom: 16px; } -.rv-header-main { - display: flex; - align-items: flex-start; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; -} +/* ── Edit button row (inside left panel, above image) ────────────────────── */ -.rv-title-row { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 8px; +.rv-edit-row { + margin-bottom: 10px; } -.rv-title { - font-family: "Space Grotesk", "Source Sans 3", sans-serif; - font-size: 1.75rem; - font-weight: 700; - margin: 0; - color: var(--cc-ink); +.rv-btn-full { + width: 100%; + justify-content: center; } -.rv-sub-badge { +.rv-btn { display: inline-flex; align-items: center; - gap: 5px; - padding: 4px 12px; - border-radius: 999px; - font-size: 0.78rem; + gap: 7px; + padding: 8px 16px; + border-radius: 10px; font-weight: 600; - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; + font-size: 0.88rem; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.18s ease; + font-family: inherit; } -.rv-meta-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; +.rv-btn-outline { + background: #ffffff; + color: #1f2a37; + border-color: #e5e7eb; + box-shadow: 0 1px 4px rgba(31, 42, 55, 0.06); } -.rv-pill { - display: inline-flex; - align-items: center; - padding: 4px 12px; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; - background: #f0f2f5; - color: var(--cc-ink); +.rv-btn-outline:hover { + background: #f3f4f6; + border-color: #d1d5db; + color: #1f2a37; } -.rv-pill-type { - background: #e6f7ed; - color: #166534; +.rv-btn-primary { + background: #0a8f3c; + color: #ffffff; + border: none; + box-shadow: 0 4px 12px rgba(10, 143, 60, 0.22); } -.rv-meta-text { - font-size: 0.85rem; - color: var(--cc-muted); +.rv-btn-primary:hover { + background: #087136; + transform: translateY(-1px); + color: #ffffff; +} + +/* ── Two-column layout ───────────────────────────────────────────────────── */ + +.rv-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 18px; + align-items: start; } -/* ── Stats ───────────────────────────────────────────────────────────────── */ +/* ── LEFT PANEL ─────────────────────────────────────────────────────────── */ -.rv-stat-group { +.rv-left { display: flex; - gap: 24px; - flex-shrink: 0; + flex-direction: column; + gap: 0; + position: sticky; + top: 20px; +} + +/* Image card */ + +.rv-image-card { + background: #ffffff; + border-radius: 14px 14px 0 0; + border: 1px solid #d1d5db; + border-bottom: none; + overflow: hidden; + aspect-ratio: 1 / 1; + width: 100%; } -.rv-stat { +.rv-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.rv-image-placeholder { + width: 100%; + height: 100%; + background: #d1d5db; display: flex; flex-direction: column; - align-items: flex-end; + align-items: center; + justify-content: center; + gap: 8px; + color: #6b7280; + font-size: 0.85rem; + text-align: center; + padding: 16px; } -.rv-stat-label { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--cc-muted); +.rv-image-placeholder i { + font-size: 2rem; + color: #9ca3af; } -.rv-stat-value { +/* Title */ + +.rv-title { + background: #ffffff; + border-left: 1px solid #d1d5db; + border-right: 1px solid #d1d5db; + padding: 14px 16px 10px; + margin: 0; + font-family: "Space Grotesk", "Source Sans 3", sans-serif; font-size: 1.15rem; font-weight: 700; - color: var(--cc-ink); + color: #1f2a37; + letter-spacing: -0.01em; + line-height: 1.3; } -.rv-stat-unit { - font-size: 0.9rem; - font-weight: 500; - color: var(--cc-muted); +/* Meta list */ + +.rv-meta-list { + list-style: none; + margin: 0; + padding: 0 0 2px; + background: #ffffff; + border-left: 1px solid #d1d5db; + border-right: 1px solid #d1d5db; +} + +.rv-meta-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 16px; + border-top: 1px solid #f3f4f6; + font-size: 0.88rem; +} + +.rv-meta-list li i { + color: #0a8f3c; + font-size: 0.85rem; + flex-shrink: 0; + width: 14px; + text-align: center; +} + +.rv-meta-label { + color: #6b7280; + flex-shrink: 0; + min-width: 56px; +} + +.rv-meta-value { + font-weight: 600; + color: #1f2a37; } -.rv-cost { - color: #166534; +/* Section blocks (dietary, allergens) */ + +.rv-section-block { + background: #ffffff; + border: 1px solid #d1d5db; + border-top: none; + padding: 12px 16px; +} + +.rv-section-block:last-child { + border-radius: 0 0 14px 14px; +} + +.rv-section-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6b7280; + margin: 0 0 8px; } -/* ── Actions row ─────────────────────────────────────────────────────────── */ +/* Allergens */ -.rv-actions { +.rv-allergen-row { display: flex; - gap: 10px; align-items: center; + gap: 7px; + font-size: 0.88rem; + font-weight: 600; + color: #92400e; } -/* ── Body ────────────────────────────────────────────────────────────────── */ +.rv-allergen-row i { + color: #f59e0b; + font-size: 0.9rem; +} -.rv-body { +/* ── RIGHT PANEL ─────────────────────────────────────────────────────────── */ + +.rv-right { display: flex; flex-direction: column; - gap: 18px; + gap: 16px; + min-width: 0; } -/* ── Shared card ─────────────────────────────────────────────────────────── */ +/* Cards */ .rv-card { - background: var(--cc-card); - border-radius: 16px; - border: 1px solid var(--cc-border); - box-shadow: var(--cc-shadow); + background: #ffffff; + border-radius: 14px; + border: 2px solid #0a8f3c; + box-shadow: 0 4px 20px rgba(10, 143, 60, 0.08); overflow: hidden; } .rv-card-header { display: flex; align-items: center; - padding: 16px 20px; + justify-content: space-between; + gap: 12px; + padding: 13px 20px; + background: #0a8f3c; + color: #ffffff; font-weight: 700; - font-size: 0.95rem; + font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; - background: var(--cc-green); - color: var(--cc-ink); - border-bottom: 1px solid var(--cc-border); -} - -.rv-count-badge { - margin-left: auto; - background: rgba(255,255,255,0.3); - padding: 2px 10px; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; } -.rv-empty { - padding: 24px 20px; - color: var(--cc-muted); +.rv-card-empty { + padding: 20px; + color: #6b7280; font-style: italic; margin: 0; } -/* ── Ingredient table ────────────────────────────────────────────────────── */ +/* Scale selector */ + +.rv-scale-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.rv-scale-label { + font-size: 0.82rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + text-transform: none; + letter-spacing: 0; +} + +.rv-scale-select { + padding: 4px 8px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.15); + color: #ffffff; + font-size: 0.82rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + outline: none; + transition: background 0.15s; +} + +.rv-scale-select option { + background: #1f2a37; + color: #ffffff; +} + +.rv-scale-select:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* Ingredients table */ + +.rv-table-wrap { + overflow-x: auto; +} -.rv-table { +.rv-ing-table { width: 100%; border-collapse: collapse; - font-size: 0.92rem; + font-size: 0.91rem; } -.rv-table thead tr { - background: #f9fafb; - border-bottom: 1px solid var(--cc-border); +.rv-ing-table thead tr { + border-bottom: 2px solid #e5e7eb; } -.rv-table thead th { - padding: 10px 14px; - font-weight: 600; - font-size: 0.8rem; +.rv-ing-table thead th { + padding: 11px 14px; + text-align: left; + font-size: 0.78rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.02em; - color: var(--cc-muted); + letter-spacing: 0.04em; + color: #6b7280; + white-space: nowrap; + background: #ffffff; } -.rv-table tbody tr { - border-bottom: 1px solid var(--cc-border); +.rv-ing-table tbody tr { + border-bottom: 1px solid #e5e7eb; + transition: background 0.12s; } -.rv-table tbody tr:last-child { +.rv-ing-table tbody tr:last-child { border-bottom: none; } -.rv-table tbody td { +.rv-ing-table tbody tr:hover { + background: #f9fafb; +} + +.rv-ing-table tbody td { padding: 12px 14px; vertical-align: middle; } -.rv-sort-num { - color: var(--cc-muted); - font-size: 0.82rem; - width: 36px; +.col-qty { + width: 110px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.col-inv { + width: 44px; text-align: center; } -.rv-subrecipe-line { +.rv-unit { + margin-left: 4px; + color: #6b7280; + font-size: 0.82rem; +} + +.rv-ing-name { + font-weight: 600; + color: #1f2a37; +} + +.rv-subrecipe-pill { display: inline-flex; align-items: center; + gap: 5px; padding: 3px 10px; border-radius: 8px; background: #eff6ff; color: #1d4ed8; - font-size: 0.88rem; + font-size: 0.84rem; font-weight: 600; } .rv-prepnote { - color: var(--cc-muted); + color: #6b7280; + font-size: 0.86rem; font-style: italic; +} + +.rv-supplier { + color: #1f2a37; font-size: 0.88rem; } -/* ── Steps list ──────────────────────────────────────────────────────────── */ +.rv-inv-link { + display: inline-flex; + align-items: center; + justify-content: center; + color: #6b7280; + font-size: 0.95rem; + transition: color 0.15s; +} + +.rv-inv-link:hover { + color: #0a8f3c; +} + +/* ── Step-by-step ────────────────────────────────────────────────────────── */ .rv-steps { - list-style: none; - padding: 0; - margin: 0; - counter-reset: step-counter; + padding: 4px 0; } .rv-step { display: flex; - gap: 16px; - padding: 18px 20px; - border-bottom: 1px solid var(--cc-border); - counter-increment: step-counter; + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid #f3f4f6; + align-items: flex-start; } .rv-step:last-child { border-bottom: none; } -.rv-step::before { - content: counter(step-counter); - display: flex; +.rv-step-num { + width: 28px; + height: 28px; + border-radius: 50%; + background: #111827; + color: #ffffff; + font-weight: 800; + font-size: 0.84rem; + display: inline-flex; align-items: center; justify-content: center; - min-width: 32px; - height: 32px; - border-radius: 999px; - background: var(--cc-green); - color: var(--cc-ink); - font-weight: 700; - font-size: 0.88rem; flex-shrink: 0; - margin-top: 2px; + margin-top: 1px; } .rv-step-body { flex: 1; + min-width: 0; } -.rv-step-instructions { - margin: 0 0 10px; - font-size: 0.95rem; - line-height: 1.6; - color: var(--cc-ink); -} - -.rv-step-instructions:only-child { - margin-bottom: 0; +.rv-step-text { + margin: 0; + font-size: 0.93rem; + line-height: 1.65; + color: #1f2a37; + white-space: pre-line; } .rv-step-meta { + margin-top: 8px; display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; } -.rv-step-tag { +.rv-step-chip { display: inline-flex; align-items: center; - padding: 3px 10px; - border-radius: 8px; + gap: 5px; + font-size: 0.77rem; + color: #6b7280; + padding: 3px 8px; + border-radius: 6px; background: #f3f4f6; - border: 1px solid var(--cc-border); + border: 1px solid #e5e7eb; +} + +.rv-step-chip i { font-size: 0.8rem; - color: var(--cc-muted); +} + +/* ── Loading spinner ─────────────────────────────────────────────────────── */ + +.rv-spinner { + width: 3rem; + height: 3rem; + border-radius: 50%; + border: 0.3rem solid #0a8f3c; + border-right-color: transparent; + animation: rv-spin 0.8s linear infinite; + display: inline-block; +} + +@keyframes rv-spin { + to { transform: rotate(360deg); } +} + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 860px) { + .rv-layout { + grid-template-columns: 1fr; + } + + .rv-left { + position: static; + } + + .rv-image-card { + aspect-ratio: 16 / 9; + max-height: 240px; + } +} + +@media (max-width: 520px) { + .rv-page { + padding: 12px 12px 32px; + } + + .rv-card-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } } diff --git a/CulinaryCommandApp/Recipe/Services/RecipeService.cs b/CulinaryCommandApp/Recipe/Services/RecipeService.cs index 0e063bc..15a562a 100644 --- a/CulinaryCommandApp/Recipe/Services/RecipeService.cs +++ b/CulinaryCommandApp/Recipe/Services/RecipeService.cs @@ -34,6 +34,7 @@ public RecipeService(AppDbContext db) return _db.Recipes .Include(r => r.RecipeIngredients) .ThenInclude(ri => ri.Ingredient) + .ThenInclude(i => i!.Vendor) .Include(r => r.RecipeIngredients) .ThenInclude(ri => ri.Unit) .Include(r => r.RecipeIngredients) @@ -47,6 +48,7 @@ public RecipeService(AppDbContext db) return _db.Recipes .Include(r => r.RecipeIngredients) .ThenInclude(ri => ri.Ingredient) + .ThenInclude(i => i!.Vendor) .Include(r => r.RecipeIngredients) .ThenInclude(ri => ri.Unit) .Include(r => r.RecipeIngredients)