feat(shop-recipes): add product linking, ingredient display, and edit functionality for recipes.

This commit is contained in:
Ho Ngoc Hai
2026-03-06 03:29:28 +07:00
parent fd75da34dc
commit a51ecacfac
2 changed files with 67 additions and 9 deletions

View File

@@ -6,7 +6,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_recipes.Count công thức</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showRecipeForm = !_showRecipeForm; _editingRecipeId = null; _newRecipeName = ""; _newRecipeInstructions = ""; _newRecipePrepTime = 5; _recipeIngredients = new(); _recipeFormMessage = null; }'>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showRecipeForm = !_showRecipeForm; _editingRecipeId = null; _selectedProductId = Guid.Empty; _newRecipeName = ""; _newRecipeInstructions = ""; _newRecipePrepTime = 5; _recipeIngredients = new(); _recipeFormMessage = null; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm công thức
</button>
</div>
@@ -18,6 +18,16 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên công thức *</label><input type="text" @bind="_newRecipeName" placeholder="VD: Cà phê sữa đá" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian chuẩn bị (phút)</label><input type="number" @bind="_newRecipePrepTime" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div style="grid-column:span 2;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sản phẩm liên kết *</label>
<select @bind="_selectedProductId" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm từ Menu --</option>
@foreach (var product in _products)
{
<option value="@product.Id">@product.Name (@(product.Price.ToString("N0"))đ)</option>
}
</select>
</div>
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hướng dẫn</label><textarea @bind="_newRecipeInstructions" rows="2" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);resize:vertical;"></textarea></div>
</div>
<div style="margin-bottom:12px;">
@@ -26,10 +36,10 @@
{
var i = idx;
<div style="display:grid;grid-template-columns:2fr 2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
@* EN: Inventory item dropdown — select raw material from inventory / VI: Dropdown chọn nguyên liệu từ kho *@
@* EN: Inventory item dropdown — select from all inventory items / VI: Dropdown chọn từ tất cả mặt hàng tồn kho *@
<select value="@(_recipeIngredients[i].InventoryItemId?.ToString() ?? "")" @onchange="@(e => OnInventoryItemSelected(i, e.Value?.ToString()))" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;">
<option value="">-- Chọn kho / Select inventory --</option>
@foreach (var item in _inventoryItems.Where(x => x.ItemType == "RawMaterial" || x.ItemType == null))
@foreach (var item in _inventoryItems)
{
<option value="@item.Id">@(item.ProductName ?? item.Id.ToString()[..8]) (@item.Unit - @(item.CostPerUnit?.ToString("N0") ?? "0")d)</option>
}
@@ -71,10 +81,29 @@ else
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="font-weight:700;font-size:14px;">@recipe.Name</div>
<div style="display:flex;gap:4px;" @onclick:stopPropagation>
<button @onclick='() => DeleteRecipeItem(recipe.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
<button @onclick='() => StartEditRecipe(recipe)' style="background:rgba(255,92,0,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#FF5C00;width:12px;height:12px;"></i></button>
<button @onclick='() => DeleteRecipeItem(recipe.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
</div>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @recipe.PrepTimeMinutes phút chuẩn bị</div>
@{ var linkedProduct = _products.FirstOrDefault(p => p.Id == recipe.ProductId); }
@if (linkedProduct != null)
{
<div style="font-size:11px;color:#FF5C00;margin-bottom:4px;"><i data-lucide="link" style="width:10px;height:10px;vertical-align:middle;"></i> @linkedProduct.Name (@(linkedProduct.Price.ToString("N0"))đ)</div>
}
@if (recipe.Ingredients != null && recipe.Ingredients.Any())
{
<div style="margin-top:8px;font-size:12px;">
<div style="font-weight:600;color:var(--admin-text-secondary);margin-bottom:4px;">Nguyên liệu:</div>
@foreach (var ing in recipe.Ingredients)
{
<div style="display:flex;justify-content:space-between;padding:2px 0;color:var(--admin-text-tertiary);">
<span>@ing.IngredientName</span>
<span>@(ing.Quantity % 1 == 0 ? ((int)ing.Quantity).ToString() : ing.Quantity.ToString("0.##")) @ing.Unit</span>
</div>
}
</div>
}
@if (isExpanded && !string.IsNullOrEmpty(recipe.Instructions))
{
<div style="font-size:12px;color:var(--admin-text-secondary);margin-top:8px;padding:8px;border-radius:6px;background:rgba(255,92,0,0.05);">@recipe.Instructions</div>
@@ -102,8 +131,10 @@ else
// EN: Recipes state / VI: Trạng thái công thức
private List<PosDataService.RecipeInfo> _recipes = new();
private List<PosDataService.InventoryItemInfo> _inventoryItems = new();
private List<PosDataService.ProductInfo> _products = new();
private bool _showRecipeForm;
private Guid? _editingRecipeId;
private Guid _selectedProductId = Guid.Empty;
private string _newRecipeName = "";
private string _newRecipeInstructions = "";
private int _newRecipePrepTime = 5;
@@ -117,13 +148,15 @@ else
{
if (ShopId != Guid.Empty)
{
// EN: Load recipes and inventory items in parallel.
// VI: Tải công thức mặt hàng tồn kho song song.
// EN: Load recipes, inventory items, and products in parallel.
// VI: Tải công thức, mặt hàng tồn kho và sản phẩm song song.
var recipesTask = DataService.GetRecipesAsync(ShopId);
var inventoryTask = DataService.GetInventoryAsync(ShopId);
await Task.WhenAll(recipesTask, inventoryTask);
var productsTask = DataService.GetProductsAsync(ShopId);
await Task.WhenAll(recipesTask, inventoryTask, productsTask);
_recipes = recipesTask.Result;
_inventoryItems = inventoryTask.Result;
_products = productsTask.Result;
}
}
@@ -146,6 +179,26 @@ else
_recipeIngredients[index].Cost = item.CostPerUnit ?? 0;
}
private void StartEditRecipe(PosDataService.RecipeInfo recipe)
{
_editingRecipeId = recipe.Id;
_selectedProductId = recipe.ProductId;
_newRecipeName = recipe.Name;
_newRecipeInstructions = recipe.Instructions ?? "";
_newRecipePrepTime = recipe.PrepTimeMinutes;
_recipeIngredients = recipe.Ingredients?.Select(ing => new IngredientRow
{
Name = ing.IngredientName,
Unit = ing.Unit,
Quantity = ing.Quantity,
Cost = ing.CostPerUnit,
InventoryItemId = ing.InventoryItemId,
QuantityPerServing = ing.QuantityPerServing
}).ToList() ?? new();
_showRecipeForm = true;
_recipeFormMessage = null;
}
// ═══ RECIPE CRUD ═══
private async Task SaveRecipe()
{
@@ -160,7 +213,11 @@ else
.Where(i => !string.IsNullOrWhiteSpace(i.Name))
.Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost, i.InventoryItemId, i.QuantityPerServing))
.ToList();
var req = new PosDataService.CreateRecipeRequest(ShopId, Guid.Empty, _newRecipeName, _newRecipeInstructions, _newRecipePrepTime, ingredients);
if (_selectedProductId == Guid.Empty)
{
_recipeFormMessage = "Vui lòng chọn sản phẩm liên kết."; _recipeFormSuccess = false; return;
}
var req = new PosDataService.CreateRecipeRequest(ShopId, _selectedProductId, _newRecipeName, _newRecipeInstructions, _newRecipePrepTime, ingredients);
bool ok;
if (_editingRecipeId.HasValue)
ok = await DataService.UpdateRecipeAsync(_editingRecipeId.Value, req);

View File

@@ -944,7 +944,8 @@ public class PosDataService
// VI: Thông tin nguyên liệu công thức — bao gồm liên kết tùy chọn đến mặt hàng tồn kho để tính giá vốn.
public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit,
Guid? InventoryItemId = null, decimal QuantityPerServing = 0);
public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt);
public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt,
List<RecipeIngredientInfo>? Ingredients = null);
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit,
Guid? InventoryItemId = null, decimal QuantityPerServing = 0);