feat(shop-recipes): add product linking, ingredient display, and edit functionality for recipes.
This commit is contained in:
@@ -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 và 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user