From 49d1e7780b8c067ede500ec19fcf745437f5d183 Mon Sep 17 00:00:00 2001 From: Kevin Tran Date: Wed, 8 Apr 2026 21:48:45 -0500 Subject: [PATCH 1/2] feat: add base recipe page view refactor --- .../Recipe/Pages/RecipeView.razor | 296 ++++++------ .../Recipe/Pages/RecipeView.razor.css | 444 ++++++++++++------ 2 files changed, 444 insertions(+), 296 deletions(-) diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor index edcddb7..26b3da4 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor @@ -8,130 +8,127 @@ @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!; - -
+ } + 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") - - } -
+ @* ── Page header ── *@ +
+
+
+

@recipe.Title

+ @if (recipe.IsSubRecipe) + { + + Sub-Recipe + + }
- -
- @if (recipe.YieldAmount.HasValue) +
+ @if (!string.IsNullOrWhiteSpace(_sourceRestaurant)) { -
- Yield - - @recipe.YieldAmount.Value.ToString("G29") - @if (!string.IsNullOrWhiteSpace(recipe.YieldUnit)) - { - @recipe.YieldUnit - } - -
+ @_sourceRestaurant } - @if (recipe.CostPerYield.HasValue) + @if (recipe.CreatedAt.HasValue) { -
- Cost / Yield - @recipe.CostPerYield.Value.ToString("C2") -
+ @if (!string.IsNullOrWhiteSpace(_sourceRestaurant)) + { + · + } + Printed @recipe.CreatedAt.Value.ToString("MMM d, yyyy") }
- -
- - Back +
+ + Back @if (_priv) { - - Edit + + Edit - }
-
- - @* ── Ingredients ── *@ -
-
- Ingredients - @recipe.RecipeIngredients.Count -
+ @* ── Quick stats ── *@ +
+
+ Category + + @(string.IsNullOrWhiteSpace(recipe.Category) ? "—" : recipe.Category) + +
+
+ Recipe Type + + @(string.IsNullOrWhiteSpace(recipe.RecipeType) ? "—" : recipe.RecipeType) + +
+
+ Yield + + @if (recipe.YieldAmount.HasValue) + { + @recipe.YieldAmount.Value.ToString("G29") + @if (!string.IsNullOrWhiteSpace(recipe.YieldUnit)) + { + @recipe.YieldUnit + } + } + else + { + @:— + } + +
+
+ Cost / Yield + + @(recipe.CostPerYield.HasValue ? recipe.CostPerYield.Value.ToString("C2") : "—") + +
+
- @if (!recipe.RecipeIngredients.Any()) - { -

No ingredients added.

- } - else - { - + @* ── Ingredients ── *@ +
+
+ + Ingredients +
+ @if (!recipe.RecipeIngredients.Any()) + { +

No ingredients added.

+ } + else + { +
+
- - - - + + + + @@ -139,85 +136,82 @@ else @foreach (var line in recipe.RecipeIngredients.OrderBy(l => l.SortOrder)) { - + - - - + + + }
#Ingredient / Sub-RecipeQuantityUnit#IngredientQuantityUnit Prep Note
@line.SortOrder@line.SortOrder @if (line.SubRecipeId.HasValue) { - - + + @(line.SubRecipe?.Title ?? $"Sub-Recipe #{line.SubRecipeId}") } else { - @(line.Ingredient?.Name ?? "—") + @(line.Ingredient?.Name ?? "—") } @line.Quantity.ToString("G29")@(line.Unit?.Abbreviation ?? "—")@(line.PrepNote ?? "—")@line.Quantity.ToString("G29") + @(line.Unit?.Abbreviation ?? "—") + @(string.IsNullOrWhiteSpace(line.PrepNote) ? "—" : line.PrepNote)
- } -
- - @* ── Steps ── *@ -
-
- Preparation Steps - @recipe.Steps.Count
+ } +
- @if (!recipe.Steps.Any()) - { -

No preparation steps added.

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

    @step.Instructions

    + @* ── Preparation 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)) + || !string.IsNullOrWhiteSpace(step.Temperature)) { -
    +
    @if (!string.IsNullOrWhiteSpace(step.Duration)) { - - @step.Duration + + @step.Duration } @if (!string.IsNullOrWhiteSpace(step.Temperature)) { - - @step.Temperature - - } - @if (!string.IsNullOrWhiteSpace(step.Equipment)) - { - - @step.Equipment + + @step.Temperature }
    }
    -
  2. - } -
- } -
- +
+
+ } +
+ }
-
-} + } +
@code { @@ -227,7 +221,7 @@ else private bool _ready; private bool _notFound; private bool _priv; - private ProduceRecipeDialog? _produceDialog; + private string? _sourceRestaurant; protected override async Task OnInitializedAsync() { @@ -252,6 +246,12 @@ 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; + } + _ready = true; } } diff --git a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css index 0291ff2..00d9ca1 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css @@ -5,317 +5,465 @@ --bg: #f6f7f9; --card: #ffffff; --green: #0a8f3c; + --green-dark: #087136; + --pill: #f0f2f5; --shadow: 0 10px 30px rgba(31, 42, 55, 0.08); } /* ── Layout ─────────────────────────────────────────────────────────────── */ -.rv-container { - max-width: 900px; +.recipe-container { + max-width: 1100px; margin: 0 auto; padding: 24px 24px 48px; - font-family: "Source Sans 3", "Segoe UI", sans-serif; - color: var(--ink); background: var(--bg); min-height: 100vh; + font-family: "Source Sans 3", "Segoe UI", sans-serif; + color: var(--ink); } -/* ── Header card ─────────────────────────────────────────────────────────── */ - -.rv-header-card { - background: var(--card); - border-radius: 16px; - border: 1px solid var(--border); - box-shadow: var(--shadow); - padding: 24px 28px; - margin-bottom: 20px; - display: flex; - flex-direction: column; - gap: 16px; +.recipe-container button, +.recipe-container .header-btn { + font-family: "Source Sans 3", "Segoe UI", sans-serif; + letter-spacing: 0.01em; + transition: all 0.2s ease; } -.rv-header-main { +/* ── Page header ─────────────────────────────────────────────────────────── */ + +.recipe-header { display: flex; align-items: flex-start; justify-content: space-between; + gap: 18px; flex-wrap: wrap; - gap: 16px; + padding: 8px 0 18px 16px; + border-left: 4px solid #2ca259; + margin-bottom: 20px; } -.rv-title-row { +.header-text { + flex: 1 1 360px; + min-width: 0; +} + +.header-text .title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; - margin-bottom: 8px; + margin-bottom: 6px; } -.rv-title { +.header-text h1 { font-family: "Space Grotesk", "Source Sans 3", sans-serif; - font-size: 1.75rem; + font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--ink); + letter-spacing: -0.01em; } -.rv-sub-badge { +.sub-badge { display: inline-flex; align-items: center; gap: 5px; - padding: 4px 12px; + padding: 4px 10px; border-radius: 999px; - font-size: 0.78rem; + font-size: 0.74rem; font-weight: 600; background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; } -.rv-meta-row { +.meta-row { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 6px 8px; + color: var(--muted); + font-size: 0.88rem; +} + +.meta-row i { + margin-right: 4px; +} + +.meta-divider { + color: #d1d5db; +} + +/* ── Header actions ──────────────────────────────────────────────────────── */ + +.header-actions { + display: flex; + gap: 10px; + align-items: center; flex-wrap: wrap; } -.rv-pill { +.header-btn { display: inline-flex; align-items: center; - padding: 4px 12px; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; - background: #f0f2f5; + gap: 8px; + padding: 9px 14px; + border-radius: 10px; + font-weight: 600; + font-size: 0.88rem; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; +} + +.header-btn.outline { + background: #ffffff; color: var(--ink); + border-color: var(--border); + box-shadow: 0 2px 6px rgba(31, 42, 55, 0.04); } -.rv-pill-type { - background: #f0fdf4; - color: #166534; +.header-btn.outline:hover { + background: #f9fafb; + border-color: #d1d5db; + color: var(--ink); } -.rv-meta-text { - font-size: 0.85rem; - color: var(--muted); +.header-btn.primary { + background: var(--green); + color: var(--ink); + border: none; + box-shadow: 0 8px 16px rgba(10, 143, 60, 0.18); } -/* ── Stats ───────────────────────────────────────────────────────────────── */ +.header-btn.primary:hover { + background: var(--green-dark); + transform: translateY(-1px); + color: var(--ink); +} -.rv-stat-group { - display: flex; - gap: 24px; - flex-shrink: 0; +/* ── Quick stats ─────────────────────────────────────────────────────────── */ + +.recipe-overview { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 18px; } -.rv-stat { +.overview-stat { + background: var(--card); + border-radius: 14px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 16px 18px; display: flex; flex-direction: column; - align-items: flex-end; + gap: 6px; + min-height: 78px; + justify-content: center; } -.rv-stat-label { - font-size: 0.75rem; +.stat-label { + font-size: 0.72rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; color: var(--muted); } -.rv-stat-value { - font-size: 1.15rem; +.stat-value { + font-family: "Space Grotesk", "Source Sans 3", sans-serif; + font-size: 1.05rem; font-weight: 700; color: var(--ink); + word-break: break-word; } -.rv-stat-unit { - font-size: 0.9rem; +.stat-unit { + font-size: 0.85rem; font-weight: 500; color: var(--muted); } -.rv-cost { +.stat-value.cost { color: #166534; } -/* ── Actions row ─────────────────────────────────────────────────────────── */ +/* ── Cards (ingredients & method) ────────────────────────────────────────── */ -.rv-actions { - display: flex; - gap: 10px; - align-items: center; -} - -/* ── Body ────────────────────────────────────────────────────────────────── */ - -.rv-body { - display: flex; - flex-direction: column; - gap: 18px; -} - -/* ── Shared card ─────────────────────────────────────────────────────────── */ - -.rv-card { +.recipe-card { background: var(--card); border-radius: 16px; border: 1px solid var(--border); box-shadow: var(--shadow); overflow: hidden; + margin-bottom: 18px; } -.rv-card-header { +.card-header-bar { display: flex; align-items: center; - padding: 16px 20px; + gap: 10px; + padding: 14px 20px; + background: #2ca259; + color: var(--ink); font-weight: 700; - font-size: 0.95rem; + font-size: 0.88rem; text-transform: uppercase; letter-spacing: 0.04em; - background: var(--green); - color: var(--ink); - border-bottom: 1px solid var(--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; +.card-header-bar i { + font-size: 1rem; } -.rv-empty { - padding: 24px 20px; - color: var(--muted); - font-style: italic; - margin: 0; +.count-badge { + display: none; } /* ── Ingredient table ────────────────────────────────────────────────────── */ -.rv-table { +.recipe-table-wrap { + width: 100%; + overflow-x: auto; + background: #ffffff; + padding: 0; +} + +.recipe-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; + background: transparent; + border: 0; + border-radius: 0; + overflow: visible; } -.rv-table thead tr { - background: #f9fafb; - border-bottom: 1px solid var(--border); +.recipe-table thead tr { + background: #ffffff; /* keep header inside the box */ } -.rv-table thead th { - padding: 10px 14px; +.recipe-table thead th { + background: #ffffff; + padding: 12px 14px; + text-align: left; font-weight: 600; - font-size: 0.8rem; + font-size: 0.78rem; text-transform: uppercase; - letter-spacing: 0.02em; + letter-spacing: 0.04em; color: var(--muted); + white-space: nowrap; } -.rv-table tbody tr { +.recipe-table tbody tr { + background: #ffffff; border-bottom: 1px solid var(--border); + transition: background 0.15s ease; } -.rv-table tbody tr:last-child { +.recipe-table tbody tr:hover { + background: #f9fafb; +} + +.recipe-table tbody tr:last-child { border-bottom: none; } -.rv-table tbody td { - padding: 12px 14px; +.recipe-table tbody td { + padding: 13px 14px; vertical-align: middle; } -.rv-sort-num { +.recipe-table .num-col { + width: 48px; + text-align: center; color: var(--muted); - font-size: 0.82rem; - width: 36px; + font-size: 0.85rem; +} + +.recipe-table .qty-col { + width: 110px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.recipe-table .unit-col { + width: 90px; + text-align: center; +} + +.recipe-table thead th.qty-col { + text-align: right; +} + +.recipe-table thead th.num-col, +.recipe-table thead th.unit-col { text-align: center; } -.rv-subrecipe-line { +.ingredient-name { + font-weight: 600; + color: var(--ink); +} + +.subrecipe-pill { display: inline-flex; align-items: center; - padding: 3px 10px; + gap: 6px; + padding: 4px 11px; border-radius: 8px; background: #eff6ff; color: #1d4ed8; - font-size: 0.88rem; + font-size: 0.86rem; font-weight: 600; } -.rv-prepnote { +.unit-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 6px; + border: 1px solid #d1d5db; + font-size: 0.78rem; + font-weight: 500; + color: #4b5563; + letter-spacing: 0.03em; + background: transparent; +} + +.prepnote { color: var(--muted); font-style: italic; - font-size: 0.88rem; + font-size: 0.86rem; } -/* ── Steps list ──────────────────────────────────────────────────────────── */ +/* ── Preparation steps (reference style) ─────────────────────────────────── */ + +.preparation-steps { + padding: 16px 18px 18px; + background: #ffffff; +} -.rv-steps { - list-style: none; +.preparation-step { padding: 0; margin: 0; - counter-reset: step-counter; + border: 0; + border-radius: 0; + box-shadow: none; } -.rv-step { - display: flex; - gap: 16px; - padding: 18px 20px; - border-bottom: 1px solid var(--border); - counter-increment: step-counter; +.preparation-step + .preparation-step { + border-top: 1px solid #f0f2f5; } -.rv-step:last-child { - border-bottom: none; +.preparation-step-row { + display: flex; + gap: 14px; + padding: 14px 0; + align-items: flex-start; } -.rv-step::before { - content: counter(step-counter); - display: flex; +.preparation-step-number { + width: 28px; + height: 28px; + border-radius: 999px; + background: #111827; /* near-black */ + border: none; + box-shadow: none; + color: #ffffff; + font-weight: 800; + font-size: 0.85rem; + display: inline-flex; align-items: center; justify-content: center; - min-width: 32px; - height: 32px; - border-radius: 999px; - background: var(--green); - color: var(--ink); - font-weight: 700; - font-size: 0.88rem; - flex-shrink: 0; + flex: 0 0 auto; margin-top: 2px; } -.rv-step-body { +.preparation-step-content { flex: 1; + min-width: 0; } -.rv-step-instructions { - margin: 0 0 10px; +.preparation-step-instructions { + margin: 0; font-size: 0.95rem; - line-height: 1.6; + line-height: 1.65; color: var(--ink); + white-space: pre-line; } -.rv-step-instructions:only-child { - margin-bottom: 0; +/* Bold the first phrase (up to the first colon) like the reference image */ +.preparation-step-instructions::first-line { + font-weight: 700; } -.rv-step-meta { +.preparation-step-submeta { + margin-top: 8px; display: flex; + gap: 14px; flex-wrap: wrap; - gap: 8px; + color: #6b7280; + font-size: 0.78rem; } -.rv-step-tag { +.step-submeta-item { display: inline-flex; align-items: center; - padding: 3px 10px; - border-radius: 8px; - background: #f3f4f6; - border: 1px solid var(--border); - font-size: 0.8rem; - color: var(--muted); + gap: 6px; +} + +.step-submeta-item i { + font-size: 0.85rem; + opacity: 0.9; +} + +/* Old clean-card styles no longer used */ +.preparation-step-head, +.preparation-step-meta, +.preparation-step-body { + display: none; +} + +/* ── Loading spinner ─────────────────────────────────────────────────────── */ + +.spinner-border { + width: 3rem; + height: 3rem; + border-width: 0.3rem; + border-color: var(--green); + border-right-color: transparent; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + .recipe-overview { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .recipe-container { + padding: 16px 14px 32px; + } + + .recipe-overview { + grid-template-columns: 1fr; + } + + .header-actions { + width: 100%; + } + + .header-btn { + flex: 1 1 auto; + justify-content: center; + } } From cc68fe0f28155e55a331bb7b6577b4d79f5075fa Mon Sep 17 00:00:00 2001 From: Anthony Phan <131195703+antphan12@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:26:01 -0500 Subject: [PATCH 2/2] Updated Recipe View Form Updated recipe view, allowing for an attachment of an image, new fields to enter (allergies, portions, etc.) UI color matches with green color used. --- ...0001_AddRecipeImageAndMetadata.Designer.cs | 1518 +++++++++++++++++ ...0260414000001_AddRecipeImageAndMetadata.cs | 52 + .../Migrations/AppDbContextModelSnapshot.cs | 11 + CulinaryCommandApp/Program.cs | 12 +- CulinaryCommandApp/Recipe/Entities/Recipe.cs | 9 + .../Recipe/Pages/RecipeEdit.razor | 13 +- .../Recipe/Pages/RecipeEdit.razor.css | 27 + .../Recipe/Pages/RecipeForm.razor | 101 ++ .../Recipe/Pages/RecipeForm.razor.css | 108 ++ .../Recipe/Pages/RecipeView.razor | 398 +++-- .../Recipe/Pages/RecipeView.razor.css | 618 ++++--- .../Recipe/Services/RecipeService.cs | 2 + 12 files changed, 2419 insertions(+), 450 deletions(-) create mode 100644 CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.Designer.cs create mode 100644 CulinaryCommandApp/Migrations/20260414000001_AddRecipeImageAndMetadata.cs create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor.css create mode 100644 CulinaryCommandApp/Recipe/Pages/RecipeForm.razor.css 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 e464ed2..837473a 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -102,6 +102,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 @@ -118,7 +123,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 26b3da4..cca1fbc 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor @@ -10,11 +10,11 @@ @(_model?.Title ?? "Recipe") -
+
@if (!_ready) {
-
+
Loading…
@@ -30,185 +30,241 @@ { var recipe = _model!; - @* ── Page header ── *@ -
-
-
-

@recipe.Title

- @if (recipe.IsSubRecipe) + @* ── Back button ── *@ + + + @* ── Two-column layout ── *@ +
+ + @* ════ LEFT PANEL ════ *@ +
-
- - Back - - @if (_priv) + @if (recipe.YieldAmount.HasValue) + { +
  • + + Yield + + @recipe.YieldAmount.Value.ToString("G29") + @if (!string.IsNullOrWhiteSpace(recipe.YieldUnit)) + { + @recipe.YieldUnit + } + +
  • + } + @if (!string.IsNullOrWhiteSpace(recipe.PortionSize)) + { +
  • + + Portion + @recipe.PortionSize +
  • + } + + + @* Allergens *@ + @if (!string.IsNullOrWhiteSpace(recipe.Allergens)) { - - Edit - +
    + +
    + + Contains: @recipe.Allergens +
    +
    } -
    -
    - @* ── Quick stats ── *@ -
    -
    - Category - - @(string.IsNullOrWhiteSpace(recipe.Category) ? "—" : recipe.Category) - -
    -
    - Recipe Type - - @(string.IsNullOrWhiteSpace(recipe.RecipeType) ? "—" : recipe.RecipeType) - -
    -
    - Yield - - @if (recipe.YieldAmount.HasValue) - { - @recipe.YieldAmount.Value.ToString("G29") - @if (!string.IsNullOrWhiteSpace(recipe.YieldUnit)) + + + + @* ════ RIGHT PANEL ════ *@ +
    + + @* ── Ingredients ── *@ +
    +
    + Ingredients + @if (recipe.YieldAmount.HasValue && recipe.YieldAmount.Value > 0) { - @recipe.YieldUnit +
    + + +
    } +
    + + @if (!recipe.RecipeIngredients.Any()) + { +

    No ingredients added.

    } else { - @:— +
    + + + + + + + + + + + + @foreach (var line in recipe.RecipeIngredients.OrderBy(l => l.SortOrder)) + { + + + + + + + + } + +
    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) + { + + + + } +
    +
    } - -
    -
    - Cost / Yield - - @(recipe.CostPerYield.HasValue ? recipe.CostPerYield.Value.ToString("C2") : "—") - -
    -
    +
    - @* ── Ingredients ── *@ -
    -
    - - Ingredients -
    - @if (!recipe.RecipeIngredients.Any()) - { -

    No ingredients added.

    - } - else - { -
    - - - - - - - - - - - - @foreach (var line in recipe.RecipeIngredients.OrderBy(l => l.SortOrder)) + @* ── Steps ── *@ +
    +
    + Step-by-Step Instructions +
    + + @if (!recipe.Steps.Any()) + { +

    No preparation steps provided.

    + } + else + { +
    + @foreach (var step in recipe.Steps.OrderBy(s => s.StepNumber)) { -
    - - - - - - - } - -
    #IngredientQuantityUnitPrep Note
    @line.SortOrder - @if (line.SubRecipeId.HasValue) - { - - - @(line.SubRecipe?.Title ?? $"Sub-Recipe #{line.SubRecipeId}") - - } - else +
    + @step.StepNumber +
    +

    @step.Instructions

    + @if (!string.IsNullOrWhiteSpace(step.Duration) + || !string.IsNullOrWhiteSpace(step.Temperature) + || !string.IsNullOrWhiteSpace(step.Equipment)) { - @(line.Ingredient?.Name ?? "—") +
    + @if (!string.IsNullOrWhiteSpace(step.Duration)) + { + + @step.Duration + + } + @if (!string.IsNullOrWhiteSpace(step.Temperature)) + { + + @step.Temperature + + } + @if (!string.IsNullOrWhiteSpace(step.Equipment)) + { + + @step.Equipment + + } +
    } -
    @line.Quantity.ToString("G29") - @(line.Unit?.Abbreviation ?? "—") - @(string.IsNullOrWhiteSpace(line.PrepNote) ? "—" : line.PrepNote)
    -
    - } -
    - - @* ── Preparation 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)) - { -
    - @if (!string.IsNullOrWhiteSpace(step.Duration)) - { - - @step.Duration - - } - @if (!string.IsNullOrWhiteSpace(step.Temperature)) - { - - @step.Temperature - - } -
    - } +
    -
    + }
    }
    - } + +
    }
    @@ -222,6 +278,7 @@ private bool _notFound; private bool _priv; private string? _sourceRestaurant; + private decimal _scaleServings = 1; protected override async Task OnInitializedAsync() { @@ -250,8 +307,35 @@ { _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 00d9ca1..41c600a 100644 --- a/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css +++ b/CulinaryCommandApp/Recipe/Pages/RecipeView.razor.css @@ -1,469 +1,519 @@ -: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 ─────────────────────────────────────────────────────────────── */ - -.recipe-container { - max-width: 1100px; +/* ── Page wrapper ────────────────────────────────────────────────────────── */ + +.rv-page { + max-width: 1200px; margin: 0 auto; - padding: 24px 24px 48px; - background: var(--bg); + padding: 20px 20px 48px; + background: #f6f7f9; min-height: 100vh; font-family: "Source Sans 3", "Segoe UI", sans-serif; - color: var(--ink); -} - -.recipe-container button, -.recipe-container .header-btn { - font-family: "Source Sans 3", "Segoe UI", sans-serif; - letter-spacing: 0.01em; - transition: all 0.2s ease; -} - -/* ── Page header ─────────────────────────────────────────────────────────── */ - -.recipe-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - flex-wrap: wrap; - padding: 8px 0 18px 16px; - border-left: 4px solid #2ca259; - margin-bottom: 20px; + color: #1f2a37; } -.header-text { - flex: 1 1 360px; - min-width: 0; -} +/* ── Back button row ─────────────────────────────────────────────────────── */ -.header-text .title-row { +.rv-back-row { display: flex; align-items: center; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 6px; + margin-bottom: 16px; } -.header-text h1 { - font-family: "Space Grotesk", "Source Sans 3", sans-serif; - font-size: 1.6rem; - font-weight: 700; - margin: 0; - color: var(--ink); - letter-spacing: -0.01em; -} +/* ── Edit button row (inside left panel, above image) ────────────────────── */ -.sub-badge { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 999px; - font-size: 0.74rem; - font-weight: 600; - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; +.rv-edit-row { + margin-bottom: 10px; } -.meta-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px 8px; - color: var(--muted); - font-size: 0.88rem; -} - -.meta-row i { - margin-right: 4px; -} - -.meta-divider { - color: #d1d5db; -} - -/* ── Header actions ──────────────────────────────────────────────────────── */ - -.header-actions { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; +.rv-btn-full { + width: 100%; + justify-content: center; } -.header-btn { +.rv-btn { display: inline-flex; align-items: center; - gap: 8px; - padding: 9px 14px; + gap: 7px; + padding: 8px 16px; border-radius: 10px; font-weight: 600; font-size: 0.88rem; text-decoration: none; cursor: pointer; border: 1px solid transparent; + transition: all 0.18s ease; + font-family: inherit; } -.header-btn.outline { +.rv-btn-outline { background: #ffffff; - color: var(--ink); - border-color: var(--border); - box-shadow: 0 2px 6px rgba(31, 42, 55, 0.04); + color: #1f2a37; + border-color: #e5e7eb; + box-shadow: 0 1px 4px rgba(31, 42, 55, 0.06); } -.header-btn.outline:hover { - background: #f9fafb; +.rv-btn-outline:hover { + background: #f3f4f6; border-color: #d1d5db; - color: var(--ink); + color: #1f2a37; } -.header-btn.primary { - background: var(--green); - color: var(--ink); +.rv-btn-primary { + background: #0a8f3c; + color: #ffffff; border: none; - box-shadow: 0 8px 16px rgba(10, 143, 60, 0.18); + box-shadow: 0 4px 12px rgba(10, 143, 60, 0.22); } -.header-btn.primary:hover { - background: var(--green-dark); +.rv-btn-primary:hover { + background: #087136; transform: translateY(-1px); - color: var(--ink); + color: #ffffff; } -/* ── Quick stats ─────────────────────────────────────────────────────────── */ +/* ── Two-column layout ───────────────────────────────────────────────────── */ -.recipe-overview { +.rv-layout { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - margin-bottom: 18px; + grid-template-columns: 280px 1fr; + gap: 18px; + align-items: start; } -.overview-stat { - background: var(--card); - border-radius: 14px; - border: 1px solid var(--border); - box-shadow: var(--shadow); - padding: 16px 18px; +/* ── LEFT PANEL ─────────────────────────────────────────────────────────── */ + +.rv-left { + display: flex; + 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-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.rv-image-placeholder { + width: 100%; + height: 100%; + background: #d1d5db; display: flex; flex-direction: column; - gap: 6px; - min-height: 78px; + align-items: center; justify-content: center; + gap: 8px; + color: #6b7280; + font-size: 0.85rem; + text-align: center; + padding: 16px; } -.stat-label { - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--muted); +.rv-image-placeholder i { + font-size: 2rem; + color: #9ca3af; } -.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.05rem; + font-size: 1.15rem; font-weight: 700; - color: var(--ink); - word-break: break-word; + color: #1f2a37; + letter-spacing: -0.01em; + line-height: 1.3; } -.stat-unit { +/* 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; - font-weight: 500; - color: var(--muted); + 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; +} + +/* 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; +} + +/* Allergens */ + +.rv-allergen-row { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.88rem; + font-weight: 600; + color: #92400e; +} + +.rv-allergen-row i { + color: #f59e0b; + font-size: 0.9rem; } -.stat-value.cost { - color: #166534; +/* ── RIGHT PANEL ─────────────────────────────────────────────────────────── */ + +.rv-right { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; } -/* ── Cards (ingredients & method) ────────────────────────────────────────── */ +/* Cards */ -.recipe-card { - background: var(--card); - border-radius: 16px; - border: 1px solid var(--border); - box-shadow: var(--shadow); +.rv-card { + background: #ffffff; + border-radius: 14px; + border: 2px solid #0a8f3c; + box-shadow: 0 4px 20px rgba(10, 143, 60, 0.08); overflow: hidden; - margin-bottom: 18px; } -.card-header-bar { +.rv-card-header { display: flex; align-items: center; - gap: 10px; - padding: 14px 20px; - background: #2ca259; - color: var(--ink); + justify-content: space-between; + gap: 12px; + padding: 13px 20px; + background: #0a8f3c; + color: #ffffff; font-weight: 700; - font-size: 0.88rem; + font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; } -.card-header-bar i { - font-size: 1rem; +.rv-card-empty { + padding: 20px; + color: #6b7280; + font-style: italic; + margin: 0; } -.count-badge { - display: none; +/* Scale selector */ + +.rv-scale-wrap { + display: flex; + align-items: center; + gap: 8px; } -/* ── Ingredient table ────────────────────────────────────────────────────── */ +.rv-scale-label { + font-size: 0.82rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + text-transform: none; + letter-spacing: 0; +} -.recipe-table-wrap { - width: 100%; +.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; - background: #ffffff; - padding: 0; } -.recipe-table { +.rv-ing-table { width: 100%; border-collapse: collapse; - font-size: 0.92rem; - background: transparent; - border: 0; - border-radius: 0; - overflow: visible; + font-size: 0.91rem; } -.recipe-table thead tr { - background: #ffffff; /* keep header inside the box */ +.rv-ing-table thead tr { + border-bottom: 2px solid #e5e7eb; } -.recipe-table thead th { - background: #ffffff; - padding: 12px 14px; +.rv-ing-table thead th { + padding: 11px 14px; text-align: left; - font-weight: 600; font-size: 0.78rem; + font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); + color: #6b7280; white-space: nowrap; -} - -.recipe-table tbody tr { background: #ffffff; - border-bottom: 1px solid var(--border); - transition: background 0.15s ease; } -.recipe-table tbody tr:hover { - background: #f9fafb; +.rv-ing-table tbody tr { + border-bottom: 1px solid #e5e7eb; + transition: background 0.12s; } -.recipe-table tbody tr:last-child { +.rv-ing-table tbody tr:last-child { border-bottom: none; } -.recipe-table tbody td { - padding: 13px 14px; - vertical-align: middle; +.rv-ing-table tbody tr:hover { + background: #f9fafb; } -.recipe-table .num-col { - width: 48px; - text-align: center; - color: var(--muted); - font-size: 0.85rem; +.rv-ing-table tbody td { + padding: 12px 14px; + vertical-align: middle; } -.recipe-table .qty-col { +.col-qty { width: 110px; - text-align: right; font-variant-numeric: tabular-nums; + white-space: nowrap; } -.recipe-table .unit-col { - width: 90px; +.col-inv { + width: 44px; text-align: center; } -.recipe-table thead th.qty-col { - text-align: right; -} - -.recipe-table thead th.num-col, -.recipe-table thead th.unit-col { - text-align: center; +.rv-unit { + margin-left: 4px; + color: #6b7280; + font-size: 0.82rem; } -.ingredient-name { +.rv-ing-name { font-weight: 600; - color: var(--ink); + color: #1f2a37; } -.subrecipe-pill { +.rv-subrecipe-pill { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 11px; + gap: 5px; + padding: 3px 10px; border-radius: 8px; background: #eff6ff; color: #1d4ed8; - font-size: 0.86rem; + font-size: 0.84rem; font-weight: 600; } -.unit-badge { - display: inline-block; - padding: 3px 10px; - border-radius: 6px; - border: 1px solid #d1d5db; - font-size: 0.78rem; - font-weight: 500; - color: #4b5563; - letter-spacing: 0.03em; - background: transparent; -} - -.prepnote { - color: var(--muted); - font-style: italic; +.rv-prepnote { + color: #6b7280; font-size: 0.86rem; + font-style: italic; } -/* ── Preparation steps (reference style) ─────────────────────────────────── */ +.rv-supplier { + color: #1f2a37; + font-size: 0.88rem; +} -.preparation-steps { - padding: 16px 18px 18px; - background: #ffffff; +.rv-inv-link { + display: inline-flex; + align-items: center; + justify-content: center; + color: #6b7280; + font-size: 0.95rem; + transition: color 0.15s; } -.preparation-step { - padding: 0; - margin: 0; - border: 0; - border-radius: 0; - box-shadow: none; +.rv-inv-link:hover { + color: #0a8f3c; } -.preparation-step + .preparation-step { - border-top: 1px solid #f0f2f5; +/* ── Step-by-step ────────────────────────────────────────────────────────── */ + +.rv-steps { + padding: 4px 0; } -.preparation-step-row { +.rv-step { display: flex; gap: 14px; - padding: 14px 0; + padding: 16px 20px; + border-bottom: 1px solid #f3f4f6; align-items: flex-start; } -.preparation-step-number { +.rv-step:last-child { + border-bottom: none; +} + +.rv-step-num { width: 28px; height: 28px; - border-radius: 999px; - background: #111827; /* near-black */ - border: none; - box-shadow: none; + border-radius: 50%; + background: #111827; color: #ffffff; font-weight: 800; - font-size: 0.85rem; + font-size: 0.84rem; display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; - margin-top: 2px; + flex-shrink: 0; + margin-top: 1px; } -.preparation-step-content { +.rv-step-body { flex: 1; min-width: 0; } -.preparation-step-instructions { +.rv-step-text { margin: 0; - font-size: 0.95rem; + font-size: 0.93rem; line-height: 1.65; - color: var(--ink); + color: #1f2a37; white-space: pre-line; } -/* Bold the first phrase (up to the first colon) like the reference image */ -.preparation-step-instructions::first-line { - font-weight: 700; -} - -.preparation-step-submeta { +.rv-step-meta { margin-top: 8px; display: flex; - gap: 14px; flex-wrap: wrap; - color: #6b7280; - font-size: 0.78rem; + gap: 10px; } -.step-submeta-item { +.rv-step-chip { display: inline-flex; align-items: center; - gap: 6px; -} - -.step-submeta-item i { - font-size: 0.85rem; - opacity: 0.9; + gap: 5px; + font-size: 0.77rem; + color: #6b7280; + padding: 3px 8px; + border-radius: 6px; + background: #f3f4f6; + border: 1px solid #e5e7eb; } -/* Old clean-card styles no longer used */ -.preparation-step-head, -.preparation-step-meta, -.preparation-step-body { - display: none; +.rv-step-chip i { + font-size: 0.8rem; } /* ── Loading spinner ─────────────────────────────────────────────────────── */ -.spinner-border { +.rv-spinner { width: 3rem; height: 3rem; - border-width: 0.3rem; - border-color: var(--green); + border-radius: 50%; + border: 0.3rem solid #0a8f3c; border-right-color: transparent; - animation: spin 0.8s linear infinite; + animation: rv-spin 0.8s linear infinite; + display: inline-block; } -@keyframes spin { +@keyframes rv-spin { to { transform: rotate(360deg); } } /* ── Responsive ──────────────────────────────────────────────────────────── */ -@media (max-width: 900px) { - .recipe-overview { - grid-template-columns: repeat(2, 1fr); +@media (max-width: 860px) { + .rv-layout { + grid-template-columns: 1fr; } -} -@media (max-width: 640px) { - .recipe-container { - padding: 16px 14px 32px; + .rv-left { + position: static; } - .recipe-overview { - grid-template-columns: 1fr; + .rv-image-card { + aspect-ratio: 16 / 9; + max-height: 240px; } +} - .header-actions { - width: 100%; +@media (max-width: 520px) { + .rv-page { + padding: 12px 12px 32px; } - .header-btn { - flex: 1 1 auto; - justify-content: center; + .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)