feat: implement recipe management, inventory operations, voucher integration, and order discounts
This commit is contained in:
@@ -192,7 +192,7 @@
|
||||
case "products":
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_products.Count) sản phẩm</h3>
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _formMessage = null; _showProductForm = !_showProductForm; })">
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _newProductCategoryId = ""; _formMessage = null; _showProductForm = !_showProductForm; })">
|
||||
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
|
||||
Thêm sản phẩm
|
||||
</button>
|
||||
@@ -213,6 +213,15 @@
|
||||
</select>
|
||||
</div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newProductDesc" class="admin-input" placeholder="Mô tả ngắn" 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;">Danh mục</label>
|
||||
<select @bind="_newProductCategoryId" 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="">-- Không phân loại --</option>
|
||||
@foreach (var c in _categories)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:16px;">
|
||||
<button class="admin-btn-primary" @onclick="@(_editingProductId.HasValue ? SaveProduct : AddProduct)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingProductId.HasValue ? "Cập nhật" : "Lưu")</button>
|
||||
@@ -313,35 +322,238 @@
|
||||
|
||||
// ═══ INVENTORY ═══
|
||||
case "inventory":
|
||||
@if (!_inventory.Any())
|
||||
@if (!_inventory.Any() && _invSubTab == "levels")
|
||||
{
|
||||
@RenderEmpty("warehouse", "#3B82F6", "Chưa có tồn kho", "Tồn kho sẽ hiển thị khi có sản phẩm", "package", "Thêm sản phẩm trước", $"/admin/shop/{ShopId}/menu")
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Stats cards -->
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
|
||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 10)</span><span class="admin-stat-card__label">Còn hàng</span></div></div>
|
||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="alert-triangle" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 0 && i.Quantity <= 10)</span><span class="admin-stat-card__label">Sắp hết</span></div></div>
|
||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);"><i data-lucide="x-circle" style="color:#EF4444;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity <= 0)</span><span class="admin-stat-card__label">Hết hàng</span></div></div>
|
||||
</div>
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mức nhập lại</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(item.Quantity <= 0 ? "#EF4444" : item.Quantity <= 10 ? "#F59E0B" : "#22C55E");">@item.Quantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tabs -->
|
||||
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;">
|
||||
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
|
||||
{
|
||||
<button @onclick="@(() => SwitchInvSubTab(val))"
|
||||
style="padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;display:flex;align-items:center;gap:6px;
|
||||
@(_invSubTab == val ? "background:white;color:var(--admin-text-primary);box-shadow:0 1px 3px rgba(0,0,0,0.1);" : "background:transparent;color:var(--admin-text-tertiary);")">
|
||||
<i data-lucide="@icon" style="width:14px;height:14px;"></i> @label
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_invFormMessage != null)
|
||||
{
|
||||
<div style="margin-top:12px;padding:12px 16px;border-radius:8px;font-size:13px;font-weight:500;background:@(_invFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_invFormSuccess ? "#16A34A" : "#DC2626");">
|
||||
@_invFormMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (_invSubTab)
|
||||
{
|
||||
case "levels":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mức nhập lại</th>
|
||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thao tác</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
var qtyColor = item.Quantity <= 0 ? "#EF4444" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "#F59E0B" : "#22C55E";
|
||||
var bgColor = item.Quantity <= 0 ? "rgba(239,68,68,0.05)" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "rgba(245,158,11,0.05)" : "transparent";
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);background:@bgColor;">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
<div style="display:flex;gap:4px;justify-content:center;">
|
||||
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
|
||||
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">+Nhập</button>
|
||||
<button @onclick="@(() => { _invSubTab = "stock-out"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
|
||||
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;">-Xuất</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "stock-in":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-down-to-line" style="width:18px;height:18px;margin-right:8px;color:#22C55E;"></i>Nhập kho</h3></div>
|
||||
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
|
||||
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);">
|
||||
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng nhập *</label>
|
||||
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Nhập số lượng..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
|
||||
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Lý do nhập kho..." />
|
||||
</div>
|
||||
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "stock-out":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-up-from-line" style="width:18px;height:18px;margin-right:8px;color:#EF4444;"></i>Xuất kho</h3></div>
|
||||
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
|
||||
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);">
|
||||
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng xuất *</label>
|
||||
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Nhập số lượng..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
|
||||
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Lý do xuất kho..." />
|
||||
</div>
|
||||
<button @onclick="DoStockOut" style="padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="arrow-up-from-line" style="width:16px;height:16px;margin-right:6px;"></i>Xuất kho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "adjust":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="settings-2" style="width:18px;height:18px;margin-right:8px;color:#3B82F6;"></i>Điều chỉnh tồn kho</h3></div>
|
||||
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
|
||||
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);">
|
||||
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
|
||||
@foreach (var item in _inventory)
|
||||
{
|
||||
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng mới *</label>
|
||||
<input type="number" @bind="_invNewQty" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Đặt số lượng chính xác..." />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Lý do *</label>
|
||||
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);" placeholder="Lý do điều chỉnh..." />
|
||||
</div>
|
||||
<button @onclick="DoAdjustStock" style="padding:10px 20px;background:#3B82F6;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
<i data-lucide="settings-2" style="width:16px;height:16px;margin-right:6px;"></i>Điều chỉnh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "transactions":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="history" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Lịch sử giao dịch kho</h3></div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@if (_invTxns.Any())
|
||||
{
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thời gian</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Lý do</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var tx in _invTxns.OrderByDescending(t => t.CreatedAt).Take(50))
|
||||
{
|
||||
var txColor = tx.QuantityChange > 0 ? "#22C55E" : tx.QuantityChange < 0 ? "#EF4444" : "#6B7280";
|
||||
var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", _ => tx.TransactionType ?? "N/A" };
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);">
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm")</td>
|
||||
<td style="padding:12px 16px;">
|
||||
<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:@(txColor)1a;color:@txColor;">@txLabel</span>
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@txColor;">@(tx.QuantityChange > 0 ? "+" : "")@tx.QuantityChange</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(tx.Reason ?? "—")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có giao dịch kho nào.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "low-stock":
|
||||
<div class="admin-panel" style="margin-top:16px;">
|
||||
<div class="admin-panel__header">
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="alert-triangle" style="width:18px;height:18px;margin-right:8px;color:#F59E0B;"></i>Cảnh báo tồn kho thấp</h3>
|
||||
<button @onclick="LoadLowStock" style="padding:6px 14px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:var(--admin-text-secondary);">
|
||||
<i data-lucide="refresh-cw" style="width:12px;height:12px;margin-right:4px;"></i>Làm mới
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@if (_lowStockItems.Any())
|
||||
{
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tồn kho</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngưỡng</th>
|
||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hành động</th>
|
||||
</tr></thead><tbody>
|
||||
@foreach (var item in _lowStockItems)
|
||||
{
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);background:rgba(245,158,11,0.03);">
|
||||
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@(item.Quantity <= 0 ? "#EF4444" : "#F59E0B");">@item.Quantity</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.LowStockThreshold</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = item.LowStockThreshold * 2; _invNotes = "Bổ sung hàng tồn kho thấp"; StateHasChanged(); })"
|
||||
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">
|
||||
<i data-lucide="arrow-down-to-line" style="width:12px;height:12px;margin-right:4px;"></i>Nhập kho nhanh
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">
|
||||
<i data-lucide="check-circle" style="width:32px;height:32px;color:#22C55E;margin-bottom:8px;"></i><br/>
|
||||
Tất cả sản phẩm đều đủ hàng!
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -463,7 +675,7 @@
|
||||
case "staff":
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_staff.Count) nhân viên</h3>
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _showStaffForm = !_showStaffForm; })">
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _showStaffForm = !_showStaffForm; })">
|
||||
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>
|
||||
Thêm nhân viên
|
||||
</button>
|
||||
@@ -485,8 +697,24 @@
|
||||
</select>
|
||||
</div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">SĐT</label><input type="text" @bind="_newStaffPhone" placeholder="0912345678" 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;">Email</label><input type="text" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" 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;">Email *</label><input type="email" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" 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>
|
||||
@if (!_editingStaffId.HasValue)
|
||||
{
|
||||
<div style="margin-top:12px;padding:12px;border-radius:8px;background:rgba(139,92,246,0.05);border:1px solid rgba(139,92,246,0.2);">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;">
|
||||
<input type="checkbox" @bind="_createStaffAccount" /> Tạo tài khoản đăng nhập (IAM)
|
||||
</label>
|
||||
@if (_createStaffAccount)
|
||||
{
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:10px;">
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Họ *</label><input type="text" @bind="_newStaffLastName" placeholder="Nguyễn" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_newStaffFirstName" placeholder="Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mật khẩu *</label><input type="password" @bind="_newStaffPassword" placeholder="Min 8 ký tự" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:white;color:var(--admin-text-primary);" /></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div style="display:flex;gap:8px;margin-top:16px;">
|
||||
<button class="admin-btn-primary" @onclick="@(_editingStaffId.HasValue ? SaveStaffEdit : AddStaff)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingStaffId.HasValue ? "Cập nhật" : "Lưu")</button>
|
||||
<button @onclick="@(() => _showStaffForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||
@@ -548,7 +776,7 @@
|
||||
<input type="text" placeholder="Tìm theo ID..." @bind="_customerSearch" @bind:event="oninput"
|
||||
style="padding:8px 12px 8px 36px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);width:200px;" />
|
||||
</div>
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showMemberForm = !_showMemberForm; _editingMemberId = null; _newMemberGender = ""; _newMemberCountry = "VN"; }'>
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showMemberForm = !_showMemberForm; _editingMemberId = null; _newMemberGender = ""; _newMemberCountry = "VN"; _newMemberName = ""; _newMemberPhone = ""; }'>
|
||||
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm khách hàng
|
||||
</button>
|
||||
</div>
|
||||
@@ -559,6 +787,8 @@
|
||||
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingMemberId.HasValue ? "Sửa khách hàng" : "Thêm khách hàng")</h3></div>
|
||||
<div class="admin-panel__body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên khách hàng *</label><input type="text" @bind="_newMemberName" placeholder="Nguyễn Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số điện thoại</label><input type="tel" @bind="_newMemberPhone" placeholder="0901234567" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giới tính</label>
|
||||
<select @bind="_newMemberGender" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
|
||||
<option value="">-- Chọn --</option>
|
||||
@@ -579,6 +809,8 @@
|
||||
var filteredMembers = string.IsNullOrWhiteSpace(_customerSearch)
|
||||
? _members
|
||||
: _members.Where(m => m.Id.ToString().Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|
||||
|| (m.DisplayName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|
||||
|| (m.Phone ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|
||||
|| (m.LevelName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
@if (!filteredMembers.Any())
|
||||
{
|
||||
@@ -619,7 +851,8 @@
|
||||
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách khách hàng</h3></div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">ID</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">SĐT</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Cấp bậc</th>
|
||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tham gia</th>
|
||||
@@ -629,7 +862,8 @@
|
||||
{
|
||||
var isExpanded = _selectedCustomerId == m.Id;
|
||||
<tr style="border-top:1px solid var(--admin-border-subtle);cursor:pointer;transition:background 0.15s;@(isExpanded ? "background:rgba(255,92,0,0.05);" : "")" @onclick='() => { _selectedCustomerId = isExpanded ? null : m.Id; StateHasChanged(); }'>
|
||||
<td style="padding:12px 16px;font-weight:600;font-family:monospace;font-size:12px;">@m.Id.ToString()[..8]</td>
|
||||
<td style="padding:12px 16px;font-weight:600;">@(m.DisplayName ?? m.Id.ToString()[..8])</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(m.Phone ?? "—")</td>
|
||||
<td style="padding:12px 16px;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(m.LevelName ?? "—")</span></td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@m.TotalExpEarned.ToString("N0")</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@m.CreatedAt.ToString("dd/MM/yyyy")</td>
|
||||
@@ -1926,6 +2160,7 @@
|
||||
private decimal _newProductPrice;
|
||||
private string _newProductType = "PreparedFood";
|
||||
private string _newProductDesc = "";
|
||||
private string _newProductCategoryId = "";
|
||||
private string? _formMessage;
|
||||
private bool _formSuccess;
|
||||
// Staff form state
|
||||
@@ -1937,6 +2172,10 @@
|
||||
private string _newStaffEmail = "";
|
||||
private string? _staffFormMessage;
|
||||
private bool _staffFormSuccess;
|
||||
private bool _createStaffAccount;
|
||||
private string _newStaffFirstName = "";
|
||||
private string _newStaffLastName = "";
|
||||
private string _newStaffPassword = "";
|
||||
private Guid? _merchantId;
|
||||
// New data: wallets, promotions, campaigns, member levels, schedules, inv txns
|
||||
private List<PosDataService.WalletInfo> _wallets = new();
|
||||
@@ -1960,6 +2199,8 @@
|
||||
private Guid? _editingMemberId;
|
||||
private string _newMemberGender = "";
|
||||
private string _newMemberCountry = "VN";
|
||||
private string _newMemberName = "";
|
||||
private string _newMemberPhone = "";
|
||||
private List<PosDataService.ScheduleInfo> _staffSchedules = new();
|
||||
private List<PosDataService.InventoryTxnInfo> _invTxns = new();
|
||||
// P2 state: calendar, KDS, treatments
|
||||
@@ -1972,6 +2213,15 @@
|
||||
private List<PosDataService.ResourceInfo> _resources = new();
|
||||
// Customer filter state
|
||||
private string _customerSearch = "";
|
||||
// Inventory sub-tab and form state
|
||||
private string _invSubTab = "levels"; // levels, stock-in, stock-out, adjust, transactions, low-stock
|
||||
private Guid _invSelectedProductId;
|
||||
private int _invAmount;
|
||||
private int _invNewQty;
|
||||
private string _invNotes = "";
|
||||
private string? _invFormMessage;
|
||||
private bool _invFormSuccess;
|
||||
private List<PosDataService.LowStockItemInfo> _lowStockItems = new();
|
||||
// Finance date range filter state
|
||||
private string _financePeriod = "all"; // 7d, 30d, all
|
||||
// Category form state
|
||||
@@ -2305,10 +2555,11 @@
|
||||
}
|
||||
try
|
||||
{
|
||||
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid) ? cid : null;
|
||||
await DataService.CreateProductAsync(new PosDataService.CreateProductRequest(
|
||||
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null));
|
||||
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
|
||||
_formMessage = $"Đã thêm '{_newProductName}' thành công!"; _formSuccess = true;
|
||||
_newProductName = ""; _newProductPrice = 0; _newProductDesc = "";
|
||||
_newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductCategoryId = "";
|
||||
_products = await DataService.GetAllProductsAsync(_shopGuid);
|
||||
}
|
||||
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
|
||||
@@ -2331,6 +2582,7 @@
|
||||
_newProductPrice = p.Price;
|
||||
_newProductType = p.Type ?? "PreparedFood";
|
||||
_newProductDesc = p.Description ?? "";
|
||||
_newProductCategoryId = p.CategoryId?.ToString() ?? "";
|
||||
_formMessage = null;
|
||||
_showProductForm = true;
|
||||
}
|
||||
@@ -2344,8 +2596,9 @@
|
||||
}
|
||||
try
|
||||
{
|
||||
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid2) ? cid2 : null;
|
||||
await DataService.UpdateProductAsync(_editingProductId.Value, new PosDataService.CreateProductRequest(
|
||||
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null));
|
||||
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
|
||||
_formMessage = $"Đã cập nhật '{_newProductName}' thành công!"; _formSuccess = true;
|
||||
_editingProductId = null;
|
||||
_products = await DataService.GetAllProductsAsync(_shopGuid);
|
||||
@@ -2362,10 +2615,25 @@
|
||||
}
|
||||
try
|
||||
{
|
||||
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
|
||||
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
|
||||
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
|
||||
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = "";
|
||||
if (_createStaffAccount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newStaffEmail) || string.IsNullOrWhiteSpace(_newStaffPassword)
|
||||
|| string.IsNullOrWhiteSpace(_newStaffFirstName) || string.IsNullOrWhiteSpace(_newStaffLastName))
|
||||
{
|
||||
_staffFormMessage = "Vui lòng nhập đầy đủ email, mật khẩu, họ và tên."; _staffFormSuccess = false; return;
|
||||
}
|
||||
var ok = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
|
||||
_newStaffEmail, _newStaffPassword, _newStaffFirstName, _newStaffLastName, _newStaffRole, _shopGuid));
|
||||
if (!ok) { _staffFormMessage = "Lỗi tạo tài khoản IAM. Kiểm tra email/mật khẩu."; _staffFormSuccess = false; return; }
|
||||
_staffFormMessage = $"Đã tạo tài khoản + mời NV '{_newStaffEmail}' thành công!"; _staffFormSuccess = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
|
||||
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
|
||||
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
|
||||
}
|
||||
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _createStaffAccount = false;
|
||||
_staff = await DataService.GetStaffAsync();
|
||||
}
|
||||
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
|
||||
@@ -2434,6 +2702,47 @@
|
||||
private void EditCategory(PosDataService.AdminCategoryInfo c) { _editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _showCategoryForm = true; _categoryFormMessage = null; }
|
||||
private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
|
||||
|
||||
// ═══ INVENTORY OPERATIONS ═══
|
||||
private async Task SwitchInvSubTab(string tab)
|
||||
{
|
||||
_invSubTab = tab;
|
||||
_invFormMessage = null;
|
||||
if (tab == "low-stock") await LoadLowStock();
|
||||
StateHasChanged();
|
||||
}
|
||||
private async Task LoadLowStock()
|
||||
{
|
||||
_lowStockItems = await DataService.GetLowStockAsync(_shopGuid);
|
||||
StateHasChanged();
|
||||
}
|
||||
private async Task DoStockIn()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; }
|
||||
var ok = await DataService.StockInAsync(new PosDataService.StockInRequest(_invSelectedProductId, _shopGuid!.Value, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
|
||||
_invFormMessage = ok ? $"Đã nhập kho thành công +{_invAmount}!" : "Lỗi khi nhập kho.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
private async Task DoStockOut()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; }
|
||||
var ok = await DataService.StockOutAsync(new PosDataService.StockOutRequest(_invSelectedProductId, _shopGuid!.Value, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
|
||||
_invFormMessage = ok ? $"Đã xuất kho thành công -{_invAmount}!" : "Lỗi khi xuất kho. Kiểm tra số lượng tồn.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
private async Task DoAdjustStock()
|
||||
{
|
||||
_invFormMessage = null;
|
||||
if (_invSelectedProductId == Guid.Empty || string.IsNullOrWhiteSpace(_invNotes)) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập lý do điều chỉnh."; _invFormSuccess = false; return; }
|
||||
var ok = await DataService.AdjustStockAsync(new PosDataService.AdjustStockRequest(_invSelectedProductId, _shopGuid!.Value, _invNewQty, _invNotes));
|
||||
_invFormMessage = ok ? $"Đã điều chỉnh tồn kho = {_invNewQty}!" : "Lỗi khi điều chỉnh.";
|
||||
_invFormSuccess = ok;
|
||||
if (ok) { _invNewQty = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
|
||||
}
|
||||
|
||||
// ═══ ORDER DETAIL ═══
|
||||
private async Task ViewOrderDetail(Guid orderId) { if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; } _selectedOrderId = orderId; _orderDetail = await DataService.GetOrderDetailAsync(orderId); }
|
||||
private async Task CancelOrderItem(Guid orderId) { var ok = await DataService.CancelOrderAsync(orderId); if (ok) { _selectedOrderId = null; _orderDetail = null; await LoadData(); } }
|
||||
@@ -2493,13 +2802,16 @@
|
||||
if (_editingMemberId.HasValue)
|
||||
ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
|
||||
else
|
||||
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry));
|
||||
if (ok) { _showMemberForm = false; _editingMemberId = null; _members = await DataService.GetMembersAsync(); }
|
||||
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry,
|
||||
string.IsNullOrWhiteSpace(_newMemberName) ? null : _newMemberName,
|
||||
string.IsNullOrWhiteSpace(_newMemberPhone) ? null : _newMemberPhone));
|
||||
if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _members = await DataService.GetMembersAsync(); }
|
||||
}
|
||||
|
||||
private void EditMember(PosDataService.MemberInfo m)
|
||||
{
|
||||
_editingMemberId = m.Id; _newMemberGender = m.Gender ?? ""; _newMemberCountry = m.CountryCode ?? "VN";
|
||||
_newMemberName = m.DisplayName ?? ""; _newMemberPhone = m.Phone ?? "";
|
||||
_showMemberForm = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,9 +83,29 @@
|
||||
}
|
||||
</div>
|
||||
<div class="pos-cart-footer">
|
||||
<!-- Voucher input -->
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||||
<input type="text" @bind="_voucherCode" placeholder="Nhập mã voucher..." style="flex:1;padding:8px 10px;border:1px solid var(--pos-border);border-radius:8px;font-size:12px;background:var(--pos-bg-elevated);color:var(--pos-text-primary);" />
|
||||
<button @onclick="ValidateVoucher" style="padding:6px 12px;border-radius:8px;border:none;background:rgba(139,92,246,0.1);color:#8B5CF6;font-size:11px;font-weight:600;cursor:pointer;">Áp dụng</button>
|
||||
@if (_appliedVoucher != null)
|
||||
{
|
||||
<button @onclick="ClearVoucher" style="padding:6px 8px;border-radius:8px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;" title="Xóa voucher"><i data-lucide="x" style="width:12px;height:12px;"></i></button>
|
||||
}
|
||||
</div>
|
||||
@if (_voucherMessage != null)
|
||||
{
|
||||
<div style="font-size:11px;margin-bottom:6px;color:@(_appliedVoucher != null ? "#16A34A" : "#EF4444");">@_voucherMessage</div>
|
||||
}
|
||||
@if (_appliedVoucher != null)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:12px;">
|
||||
<span style="color:var(--pos-text-tertiary);">Giảm giá (@_appliedVoucher.CampaignName)</span>
|
||||
<span style="color:#16A34A;font-weight:600;">-@FormatPrice(_discountAmount)</span>
|
||||
</div>
|
||||
}
|
||||
<div class="pos-cart-total">
|
||||
<span class="pos-cart-total__label">Tổng cộng</span>
|
||||
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
|
||||
<span class="pos-cart-total__value">@FormatPrice(FinalTotal)</span>
|
||||
</div>
|
||||
<button class="pos-btn-checkout" @onclick="StartPayment" disabled="@(!_cartItems.Any())">
|
||||
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
|
||||
@@ -102,7 +122,7 @@
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;">Thanh toán</span>
|
||||
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
|
||||
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
|
||||
</div>
|
||||
<div class="pos-payment-methods">
|
||||
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("cash")'>
|
||||
@@ -133,7 +153,7 @@
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;">💵 Tiền mặt</span>
|
||||
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
|
||||
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
|
||||
</div>
|
||||
<div class="pos-payment-amount-section">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Số tiền nhanh</div>
|
||||
@@ -168,7 +188,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
|
||||
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < CartTotal)">
|
||||
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal)">
|
||||
Xác nhận thanh toán
|
||||
</button>
|
||||
</div>
|
||||
@@ -185,7 +205,7 @@
|
||||
<span style="font-weight:600;">@GetMethodLabel()</span>
|
||||
</div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;">
|
||||
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</div>
|
||||
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</div>
|
||||
@if (_selectedMethod == "qr")
|
||||
{
|
||||
<div style="width:180px;height:180px;background:white;border-radius:12px;display:flex;align-items:center;justify-content:center;">
|
||||
@@ -464,6 +484,13 @@
|
||||
private IEnumerable<Product> FilteredProducts =>
|
||||
_selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
|
||||
private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
|
||||
private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount);
|
||||
|
||||
// Voucher state
|
||||
private string _voucherCode = "";
|
||||
private string? _voucherMessage;
|
||||
private PosDataService.VoucherValidationInfo? _appliedVoucher;
|
||||
private decimal _discountAmount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -478,7 +505,7 @@
|
||||
var apiProducts = await productsTask;
|
||||
var apiCategories = await categoriesTask;
|
||||
|
||||
_products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.Category ?? "Khác")).ToList();
|
||||
_products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.CategoryName ?? "Khác")).ToList();
|
||||
|
||||
var catNames = apiCategories.Select(c => c.Name).ToList();
|
||||
if (catNames.Count > 0)
|
||||
@@ -507,6 +534,20 @@
|
||||
if (item.Qty <= 0) _cartItems.Remove(item);
|
||||
}
|
||||
|
||||
// ═══════════════ VOUCHER ═══════════════
|
||||
private async Task ValidateVoucher()
|
||||
{
|
||||
_voucherMessage = null; _appliedVoucher = null; _discountAmount = 0;
|
||||
if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; }
|
||||
var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim());
|
||||
if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; }
|
||||
if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; }
|
||||
_appliedVoucher = info;
|
||||
_discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal);
|
||||
_voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}";
|
||||
}
|
||||
private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; }
|
||||
|
||||
// ═══════════════ INLINE PAYMENT ═══════════════
|
||||
private enum PayStep { None, MethodSelect, AmountInput, Processing, Success }
|
||||
private PayStep _paymentStep = PayStep.None;
|
||||
@@ -517,7 +558,7 @@
|
||||
private string _lastTransactionId = "";
|
||||
private string _lastPaymentMethod = "";
|
||||
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
|
||||
private decimal ChangeAmount => _receivedAmount - CartTotal;
|
||||
private decimal ChangeAmount => _receivedAmount - FinalTotal;
|
||||
|
||||
private void StartPayment()
|
||||
{
|
||||
@@ -556,7 +597,7 @@
|
||||
|
||||
private List<(string Label, decimal Value)> GetQuickAmounts()
|
||||
{
|
||||
var total = CartTotal;
|
||||
var total = FinalTotal;
|
||||
var amounts = new List<(string, decimal)>();
|
||||
var roundUp = Math.Ceiling(total / 50_000) * 50_000;
|
||||
if (roundUp == total) roundUp += 50_000;
|
||||
@@ -583,7 +624,7 @@
|
||||
_paymentProcessing = true;
|
||||
StateHasChanged();
|
||||
|
||||
_lastOrderTotal = CartTotal;
|
||||
_lastOrderTotal = FinalTotal;
|
||||
_lastPaymentMethod = _selectedMethod;
|
||||
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
|
||||
var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
|
||||
@@ -596,7 +637,10 @@
|
||||
ShopId,
|
||||
_selectedMethod,
|
||||
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
|
||||
i.ProductId, i.Name, i.Qty, i.Price)).ToList());
|
||||
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
|
||||
_discountAmount > 0 ? _discountAmount : null,
|
||||
_appliedVoucher != null ? "voucher" : null,
|
||||
_appliedVoucher?.VoucherCode);
|
||||
|
||||
var result = await DataService.CreatePosOrderAsync(orderReq);
|
||||
_lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
|
||||
@@ -636,6 +680,7 @@
|
||||
_selectedMethod = "";
|
||||
_receivedAmount = 0;
|
||||
_customAmountInput = "";
|
||||
ClearVoucher();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -109,7 +109,7 @@ public class PosDataService
|
||||
}
|
||||
|
||||
public record ShopInfo(Guid Id, string Name, string Slug, string? Description, string? Phone, string? Email, string? Category, string? Status, Guid? MerchantId = null);
|
||||
public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? Category, int? DurationMinutes);
|
||||
public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? CategoryName, int? DurationMinutes, Guid? CategoryId = null);
|
||||
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder);
|
||||
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt);
|
||||
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
|
||||
@@ -141,11 +141,11 @@ public class PosDataService
|
||||
// EN: Admin-level records with shop_id and category info
|
||||
// VI: Record cấp admin với shop_id và thông tin danh mục
|
||||
public record AdminProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description,
|
||||
string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName);
|
||||
string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName, Guid? CategoryId = null);
|
||||
public record AdminCategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder,
|
||||
Guid ShopId, Guid? ParentId, bool IsActive);
|
||||
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price,
|
||||
string? Type, string? Sku, string? ImageUrl);
|
||||
string? Type, string? Sku, string? ImageUrl, Guid? CategoryId = null);
|
||||
|
||||
public async Task<List<AdminProductInfo>> GetAllProductsAsync(Guid? shopId = null)
|
||||
{
|
||||
@@ -194,10 +194,14 @@ public class PosDataService
|
||||
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
|
||||
|
||||
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
|
||||
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
|
||||
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName,
|
||||
string? DisplayName = null, string? Phone = null);
|
||||
|
||||
public async Task<List<MemberInfo>> GetMembersAsync()
|
||||
=> await GetListFromApiAsync<MemberInfo>("api/bff/members");
|
||||
public async Task<List<MemberInfo>> GetMembersAsync(string? search = null)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(search) ? "api/bff/members" : $"api/bff/members?search={Uri.EscapeDataString(search)}";
|
||||
return await GetListFromApiAsync<MemberInfo>(url);
|
||||
}
|
||||
|
||||
// ═══ STAFF CREATE ═══
|
||||
|
||||
@@ -323,7 +327,7 @@ public class PosDataService
|
||||
|
||||
// EN: Member create/update request DTOs
|
||||
// VI: DTO tạo/cập nhật thành viên
|
||||
public record CreateMemberRequest(string? Gender, string? CountryCode);
|
||||
public record CreateMemberRequest(string? Gender, string? CountryCode, string? Name = null, string? Phone = null);
|
||||
public record UpdateMemberRequest(string? Gender, string? Preferences);
|
||||
|
||||
public async Task<bool> CreateMemberAsync(CreateMemberRequest req)
|
||||
@@ -357,6 +361,43 @@ public class PosDataService
|
||||
return await GetListFromApiAsync<InventoryTxnInfo>(url);
|
||||
}
|
||||
|
||||
// ═══ INVENTORY OPERATIONS ═══
|
||||
|
||||
public record StockInRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes);
|
||||
|
||||
public async Task<bool> StockInAsync(StockInRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-in", req, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public record StockOutRequest(Guid ProductId, Guid ShopId, int Amount, string? Notes);
|
||||
|
||||
public async Task<bool> StockOutAsync(StockOutRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-out", req, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public record AdjustStockRequest(Guid ProductId, Guid ShopId, int NewQuantity, string Notes);
|
||||
|
||||
public async Task<bool> AdjustStockAsync(AdjustStockRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/inventory/adjust", req, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public record LowStockItemInfo(Guid Id, Guid ProductId, string? ProductName, int Quantity, int LowStockThreshold, Guid ShopId);
|
||||
|
||||
public async Task<List<LowStockItemInfo>> GetLowStockAsync(Guid? shopId = null)
|
||||
{
|
||||
var url = shopId.HasValue ? $"api/bff/inventory/low-stock?shopId={shopId}" : "api/bff/inventory/low-stock";
|
||||
return await GetListFromApiAsync<LowStockItemInfo>(url);
|
||||
}
|
||||
|
||||
// ═══ MEMBERSHIP LEVELS ═══
|
||||
|
||||
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
|
||||
@@ -417,7 +458,8 @@ public class PosDataService
|
||||
|
||||
// EN: POS order creation DTOs
|
||||
// VI: DTOs cho tạo đơn POS
|
||||
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
|
||||
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items,
|
||||
decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null);
|
||||
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, string? ProductType = "Physical");
|
||||
public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status);
|
||||
|
||||
@@ -623,4 +665,38 @@ public class PosDataService
|
||||
|
||||
public async Task<bool> DeleteRecipeAsync(Guid recipeId)
|
||||
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; }
|
||||
|
||||
// ═══ VOUCHER VALIDATION ═══
|
||||
|
||||
public record VoucherValidationInfo(bool IsValid, string? ErrorMessage, Guid? VoucherId,
|
||||
string? VoucherCode, decimal? RemainingValue, DateTime? ExpiresAt, string? CampaignName);
|
||||
|
||||
public async Task<VoucherValidationInfo?> ValidateVoucherAsync(string code)
|
||||
=> await GetObjectFromApiAsync<VoucherValidationInfo>($"api/bff/vouchers/validate/{Uri.EscapeDataString(code)}");
|
||||
|
||||
public async Task<bool> RedeemVoucherAsync(Guid voucherId, decimal amount)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/vouchers/redeem", new { voucherId, amount }, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
// ═══ CAMPAIGN ACTIONS ═══
|
||||
|
||||
public async Task<bool> ActivateCampaignAsync(Guid campaignId)
|
||||
{ AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/activate", null); return r.IsSuccessStatusCode; }
|
||||
|
||||
public async Task<bool> PauseCampaignAsync(Guid campaignId)
|
||||
{ AttachToken(); var r = await _http.PostAsync($"api/bff/campaigns/{campaignId}/pause", null); return r.IsSuccessStatusCode; }
|
||||
|
||||
// ═══ STAFF IAM ═══
|
||||
|
||||
public record InviteStaffWithAccountRequest(string Email, string Password, string FirstName, string LastName, string Role, Guid? ShopId);
|
||||
|
||||
public async Task<bool> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +160,42 @@ public class FinancialController : ControllerBase
|
||||
[HttpDelete("campaigns/{campaignId:guid}")]
|
||||
public Task<IActionResult> DeleteCampaign(Guid campaignId) =>
|
||||
_promotion.DeleteAsync($"/api/v1/campaigns/{campaignId}").ProxyAsync();
|
||||
|
||||
// ═══ VOUCHER ENDPOINTS ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate a voucher code.
|
||||
/// VI: Kiểm tra mã voucher.
|
||||
/// </summary>
|
||||
[HttpGet("vouchers/validate/{code}")]
|
||||
public async Task<IActionResult> ValidateVoucher(string code)
|
||||
{
|
||||
var userId = GetUserIdFromToken();
|
||||
var qs = userId.HasValue ? $"?userId={userId}" : "";
|
||||
return await _promotion.GetAsync($"/api/v1/vouchers/validate/{code}{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Redeem a voucher.
|
||||
/// VI: Sử dụng voucher.
|
||||
/// </summary>
|
||||
[HttpPost("vouchers/redeem")]
|
||||
public Task<IActionResult> RedeemVoucher([FromBody] JsonElement body) =>
|
||||
_promotion.PostAsJsonAsync("/api/v1/vouchers/redeem", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate a campaign.
|
||||
/// VI: Kích hoạt chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("campaigns/{campaignId:guid}/activate")]
|
||||
public Task<IActionResult> ActivateCampaign(Guid campaignId) =>
|
||||
_promotion.PostAsync($"/api/v1/campaigns/{campaignId}/activate", null).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pause a campaign.
|
||||
/// VI: Tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("campaigns/{campaignId:guid}/pause")]
|
||||
public Task<IActionResult> PauseCampaign(Guid campaignId) =>
|
||||
_promotion.PostAsync($"/api/v1/campaigns/{campaignId}/pause", null).ProxyAsync();
|
||||
}
|
||||
|
||||
@@ -48,4 +48,39 @@ public class InventoryController : ControllerBase
|
||||
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
|
||||
return _inventory.GetAsync($"/api/v1/inventory/transactions{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stock in — add quantity to inventory.
|
||||
/// VI: Nhập kho — thêm số lượng vào tồn kho.
|
||||
/// </summary>
|
||||
[HttpPost("inventory/stock-in")]
|
||||
public Task<IActionResult> StockIn([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/stock-in", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stock out — remove quantity from inventory.
|
||||
/// VI: Xuất kho — trừ số lượng khỏi tồn kho.
|
||||
/// </summary>
|
||||
[HttpPost("inventory/stock-out")]
|
||||
public Task<IActionResult> StockOut([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/stock-out", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Adjust stock — set exact quantity.
|
||||
/// VI: Điều chỉnh kho — đặt số lượng chính xác.
|
||||
/// </summary>
|
||||
[HttpPost("inventory/adjust")]
|
||||
public Task<IActionResult> AdjustStock([FromBody] JsonElement body) =>
|
||||
_inventory.PostAsJsonAsync("/api/v1/inventory/adjust", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get low stock alerts.
|
||||
/// VI: Lấy cảnh báo hàng tồn kho thấp.
|
||||
/// </summary>
|
||||
[HttpGet("inventory/low-stock")]
|
||||
public Task<IActionResult> GetLowStock([FromQuery] Guid? shopId = null)
|
||||
{
|
||||
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
|
||||
return _inventory.GetAsync($"/api/v1/inventory/low-stock{qs}").ProxyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,20 @@ public class MembershipController : ControllerBase
|
||||
/// VI: Lấy danh sách thành viên (khách hàng).
|
||||
/// </summary>
|
||||
[HttpGet("members")]
|
||||
public Task<IActionResult> GetMembers() =>
|
||||
_membership.GetAsync("/api/v1/members").ProxyAsync();
|
||||
public Task<IActionResult> GetMembers([FromQuery] string? search = null, [FromQuery] int pageSize = 100)
|
||||
{
|
||||
var qs = $"?pageSize={pageSize}";
|
||||
if (!string.IsNullOrWhiteSpace(search)) qs += $"&search={Uri.EscapeDataString(search)}";
|
||||
return _membership.GetAsync($"/api/v1/members{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a member by ID.
|
||||
/// VI: Lấy thành viên theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("members/{memberId:guid}")]
|
||||
public Task<IActionResult> GetMemberById(Guid memberId) =>
|
||||
_membership.GetAsync($"/api/v1/members/{memberId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a member.
|
||||
|
||||
@@ -14,11 +14,13 @@ public class StaffController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _merchant;
|
||||
private readonly HttpClient _booking;
|
||||
private readonly HttpClient _iam;
|
||||
|
||||
public StaffController(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_merchant = httpClientFactory.CreateClient("MerchantService");
|
||||
_booking = httpClientFactory.CreateClient("BookingService");
|
||||
_iam = httpClientFactory.CreateClient("IamService");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +39,39 @@ public class StaffController : ControllerBase
|
||||
public Task<IActionResult> CreateStaff([FromBody] JsonElement body) =>
|
||||
_merchant.PostAsJsonAsync("/api/v1/merchants/me/staff", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create IAM account + invite staff in one call.
|
||||
/// VI: Tạo tài khoản IAM + mời nhân viên trong một lần gọi.
|
||||
/// </summary>
|
||||
[HttpPost("staff/invite-with-account")]
|
||||
public async Task<IActionResult> InviteStaffWithAccount([FromBody] JsonElement body)
|
||||
{
|
||||
// Step 1: Create IAM account
|
||||
var iamPayload = new
|
||||
{
|
||||
email = body.TryGetProperty("email", out var e) ? e.GetString() : "",
|
||||
password = body.TryGetProperty("password", out var pw) ? pw.GetString() : "",
|
||||
firstName = body.TryGetProperty("firstName", out var fn) ? fn.GetString() : "",
|
||||
lastName = body.TryGetProperty("lastName", out var ln) ? ln.GetString() : ""
|
||||
};
|
||||
var iamResponse = await _iam.PostAsJsonAsync("/api/v1/auth/register", iamPayload);
|
||||
if (!iamResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await iamResponse.Content.ReadAsStringAsync();
|
||||
return StatusCode((int)iamResponse.StatusCode,
|
||||
new { success = false, message = "IAM account creation failed", details = err });
|
||||
}
|
||||
|
||||
// Step 2: Invite staff via MerchantService
|
||||
var invitePayload = new
|
||||
{
|
||||
email = iamPayload.email,
|
||||
role = body.TryGetProperty("role", out var r) ? r.GetString() : "Cashier",
|
||||
shopId = body.TryGetProperty("shopId", out var s) ? s.GetString() : null as string
|
||||
};
|
||||
return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a staff member.
|
||||
/// VI: Cập nhật nhân viên.
|
||||
|
||||
@@ -116,6 +116,7 @@ AddServiceClient("WalletService", "WalletService__BaseUrl", "http://loca
|
||||
AddServiceClient("PromotionService", "PromotionService__BaseUrl", "http://localhost:5008");
|
||||
AddServiceClient("BookingService", "BookingService__BaseUrl", "http://localhost:5020");
|
||||
AddServiceClient("FnbEngine", "FnbEngine__BaseUrl", "http://localhost:5019");
|
||||
AddServiceClient("IamService", "IamService__BaseUrl", "http://localhost:5001");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -58,4 +58,10 @@ public record CreateProductCommand : IRequest<Guid>
|
||||
/// VI: URL hình ảnh sản phẩm.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category ID for product classification.
|
||||
/// VI: ID danh mục để phân loại sản phẩm.
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; init; }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand,
|
||||
type: productType,
|
||||
description: request.Description,
|
||||
attributes: attributesJson,
|
||||
sku: request.Sku);
|
||||
sku: request.Sku,
|
||||
categoryId: request.CategoryId);
|
||||
|
||||
// EN: Update image if provided
|
||||
// VI: Cập nhật image nếu được cung cấp
|
||||
|
||||
@@ -46,4 +46,10 @@ public record UpdateProductCommand : IRequest<bool>
|
||||
/// VI: URL hình ảnh sản phẩm.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category ID for product classification.
|
||||
/// VI: ID danh mục để phân loại sản phẩm.
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; init; }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand,
|
||||
// VI: Cập nhật thông tin cơ bản
|
||||
product.UpdateInfo(request.Name, request.Description, request.Price);
|
||||
|
||||
// EN: Update category
|
||||
// VI: Cập nhật danh mục
|
||||
product.SetCategory(request.CategoryId);
|
||||
|
||||
// EN: Update attributes if provided
|
||||
// VI: Cập nhật attributes nếu được cung cấp
|
||||
if (request.Attributes != null)
|
||||
|
||||
@@ -63,6 +63,18 @@ public record ProductDto
|
||||
/// </summary>
|
||||
public string? Sku { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category ID.
|
||||
/// VI: ID danh mục.
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category name (resolved from lookup).
|
||||
/// VI: Tên danh mục (resolve từ lookup).
|
||||
/// </summary>
|
||||
public string? CategoryName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Is product active.
|
||||
/// VI: Sản phẩm có đang hoạt động không.
|
||||
|
||||
@@ -33,6 +33,15 @@ public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, P
|
||||
|
||||
if (entity == null) return null;
|
||||
|
||||
// EN: Resolve category name if categoryId exists
|
||||
// VI: Resolve tên danh mục nếu có categoryId
|
||||
string? categoryName = null;
|
||||
if (entity.CategoryId.HasValue)
|
||||
{
|
||||
var cat = await _context.Categories.FirstOrDefaultAsync(c => c.Id == entity.CategoryId.Value, cancellationToken);
|
||||
categoryName = cat?.Name;
|
||||
}
|
||||
|
||||
return new ProductDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
@@ -46,6 +55,8 @@ public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, P
|
||||
: null,
|
||||
ImageUrl = entity.ImageUrl,
|
||||
Sku = entity.Sku,
|
||||
CategoryId = entity.CategoryId,
|
||||
CategoryName = categoryName,
|
||||
IsActive = entity.IsActive,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
|
||||
@@ -30,6 +30,12 @@ public record GetProductsQuery : IRequest<PagedResult<ProductDto>>
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by category ID (null = all).
|
||||
/// VI: Lọc theo danh mục (null = tất cả).
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page number (1-indexed).
|
||||
/// VI: Số trang (bắt đầu từ 1).
|
||||
|
||||
@@ -44,6 +44,11 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
|
||||
query = query.Where(p => p.TypeId == typeEnum.Id);
|
||||
}
|
||||
|
||||
if (request.CategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.CategoryId == request.CategoryId.Value);
|
||||
}
|
||||
|
||||
// EN: Get total count
|
||||
// VI: Lấy tổng số
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
@@ -62,6 +67,14 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
|
||||
// VI: Build lookup TypeId → Name từ Enumeration.
|
||||
var typeMap = Enumeration.GetAll<ProductType>().ToDictionary(t => t.Id, t => t.Name);
|
||||
|
||||
// EN: Build CategoryId → Name lookup.
|
||||
// VI: Build lookup CategoryId → Name.
|
||||
var categoryIds = entities.Where(p => p.CategoryId.HasValue).Select(p => p.CategoryId!.Value).Distinct().ToList();
|
||||
var categoryMap = categoryIds.Count > 0
|
||||
? await _context.Categories.Where(c => categoryIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken)
|
||||
: new Dictionary<Guid, string>();
|
||||
|
||||
var products = entities.Select(p => new ProductDto
|
||||
{
|
||||
Id = p.Id,
|
||||
@@ -75,6 +88,8 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
|
||||
: null,
|
||||
ImageUrl = p.ImageUrl,
|
||||
Sku = p.Sku,
|
||||
CategoryId = p.CategoryId,
|
||||
CategoryName = p.CategoryId.HasValue && categoryMap.TryGetValue(p.CategoryId.Value, out var catName) ? catName : null,
|
||||
IsActive = p.IsActive,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt
|
||||
|
||||
@@ -40,6 +40,7 @@ public class ProductsController : ControllerBase
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] bool? isActive = null,
|
||||
[FromQuery] string? type = null,
|
||||
[FromQuery] Guid? categoryId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -49,6 +50,7 @@ public class ProductsController : ControllerBase
|
||||
ShopId = shopId,
|
||||
IsActive = isActive,
|
||||
Type = type,
|
||||
CategoryId = categoryId,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
@@ -67,6 +69,7 @@ public class ProductsController : ControllerBase
|
||||
Guid shopId,
|
||||
[FromQuery] bool? isActive = null,
|
||||
[FromQuery] string? type = null,
|
||||
[FromQuery] Guid? categoryId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -76,6 +79,7 @@ public class ProductsController : ControllerBase
|
||||
ShopId = shopId,
|
||||
IsActive = isActive,
|
||||
Type = type,
|
||||
CategoryId = categoryId,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ public class Product : Entity, IAggregateRoot
|
||||
private JsonDocument? _attributes; // Type-specific attributes in JSONB
|
||||
private string? _imageUrl;
|
||||
private string? _sku;
|
||||
private Guid? _categoryId;
|
||||
private bool _isActive;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
@@ -73,6 +74,12 @@ public class Product : Entity, IAggregateRoot
|
||||
/// </summary>
|
||||
public string? Sku => _sku;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category ID for product classification.
|
||||
/// VI: ID danh mục để phân loại sản phẩm.
|
||||
/// </summary>
|
||||
public Guid? CategoryId => _categoryId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Is product active and available for sale.
|
||||
/// VI: Sản phẩm có đang hoạt động và sẵn sàng bán không.
|
||||
@@ -110,7 +117,8 @@ public class Product : Entity, IAggregateRoot
|
||||
ProductType type,
|
||||
string? description = null,
|
||||
JsonDocument? attributes = null,
|
||||
string? sku = null)
|
||||
string? sku = null,
|
||||
Guid? categoryId = null)
|
||||
{
|
||||
if (shopId == Guid.Empty)
|
||||
throw new DomainException("Shop ID cannot be empty");
|
||||
@@ -128,6 +136,7 @@ public class Product : Entity, IAggregateRoot
|
||||
TypeId = type.Id;
|
||||
_attributes = attributes;
|
||||
_sku = sku?.Trim();
|
||||
_categoryId = categoryId;
|
||||
_isActive = true;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
@@ -184,6 +193,16 @@ public class Product : Entity, IAggregateRoot
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set category for product.
|
||||
/// VI: Đặt danh mục cho sản phẩm.
|
||||
/// </summary>
|
||||
public void SetCategory(Guid? categoryId)
|
||||
{
|
||||
_categoryId = categoryId;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate product.
|
||||
/// VI: Vô hiệu hóa sản phẩm.
|
||||
|
||||
@@ -77,6 +77,10 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
|
||||
.HasColumnName("sku")
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(p => p.CategoryId)
|
||||
.HasField("_categoryId")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
builder.Property(p => p.IsActive)
|
||||
.HasField("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
@@ -97,6 +101,7 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
|
||||
builder.HasIndex(p => p.TypeId).HasDatabaseName("ix_products_type_id");
|
||||
builder.HasIndex(p => p.Sku).HasDatabaseName("ix_products_sku");
|
||||
builder.HasIndex(p => p.IsActive).HasDatabaseName("ix_products_is_active");
|
||||
builder.HasIndex(p => p.CategoryId).HasDatabaseName("ix_products_category_id");
|
||||
|
||||
// EN: TypeId is a plain FK column — no navigation property to ProductType.
|
||||
// Type name is resolved from Enumeration in query handlers.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using MediatR;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
namespace FnbEngine.API.Application.Commands;
|
||||
|
||||
public class CreateRecipeCommandHandler : IRequestHandler<CreateRecipeCommand, Guid>
|
||||
{
|
||||
private readonly IRecipeRepository _repo;
|
||||
public CreateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<Guid> Handle(CreateRecipeCommand req, CancellationToken ct)
|
||||
{
|
||||
var recipe = new Recipe(req.ShopId, req.ProductId, req.Name, req.Instructions, req.PrepTimeMinutes);
|
||||
if (req.Ingredients != null)
|
||||
{
|
||||
foreach (var ing in req.Ingredients)
|
||||
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit);
|
||||
}
|
||||
_repo.Add(recipe);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return recipe.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateRecipeCommandHandler : IRequestHandler<UpdateRecipeCommand, bool>
|
||||
{
|
||||
private readonly IRecipeRepository _repo;
|
||||
public UpdateRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(UpdateRecipeCommand req, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _repo.GetByIdAsync(req.RecipeId, ct);
|
||||
if (recipe == null) return false;
|
||||
|
||||
recipe.Update(req.Name, req.Instructions, req.PrepTimeMinutes);
|
||||
recipe.ClearIngredients();
|
||||
if (req.Ingredients != null)
|
||||
{
|
||||
foreach (var ing in req.Ingredients)
|
||||
recipe.AddIngredient(ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit);
|
||||
}
|
||||
_repo.Update(recipe);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteRecipeCommandHandler : IRequestHandler<DeleteRecipeCommand, bool>
|
||||
{
|
||||
private readonly IRecipeRepository _repo;
|
||||
public DeleteRecipeCommandHandler(IRecipeRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(DeleteRecipeCommand req, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _repo.GetByIdAsync(req.RecipeId, ct);
|
||||
if (recipe == null) return false;
|
||||
recipe.Deactivate();
|
||||
_repo.Update(recipe);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FnbEngine.API.Application.Commands;
|
||||
|
||||
public record CreateRecipeCommand : IRequest<Guid>
|
||||
{
|
||||
public Guid ShopId { get; init; }
|
||||
public Guid ProductId { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public string? Instructions { get; init; }
|
||||
public int PrepTimeMinutes { get; init; }
|
||||
public List<IngredientItem>? Ingredients { get; init; }
|
||||
}
|
||||
|
||||
public record UpdateRecipeCommand : IRequest<bool>
|
||||
{
|
||||
public Guid RecipeId { get; init; }
|
||||
public Guid ShopId { get; init; }
|
||||
public Guid ProductId { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public string? Instructions { get; init; }
|
||||
public int PrepTimeMinutes { get; init; }
|
||||
public List<IngredientItem>? Ingredients { get; init; }
|
||||
}
|
||||
|
||||
public record DeleteRecipeCommand(Guid RecipeId) : IRequest<bool>;
|
||||
|
||||
public record IngredientItem(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
namespace FnbEngine.API.Application.Queries;
|
||||
|
||||
public record GetRecipesByShopQuery(Guid ShopId) : IRequest<IEnumerable<RecipeDto>>;
|
||||
|
||||
public record RecipeDto(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions,
|
||||
int PrepTimeMinutes, bool IsActive, DateTime CreatedAt, List<RecipeIngredientDto> Ingredients);
|
||||
|
||||
public record RecipeIngredientDto(Guid Id, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||
|
||||
public class GetRecipesByShopQueryHandler : IRequestHandler<GetRecipesByShopQuery, IEnumerable<RecipeDto>>
|
||||
{
|
||||
private readonly IRecipeRepository _repo;
|
||||
public GetRecipesByShopQueryHandler(IRecipeRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<IEnumerable<RecipeDto>> Handle(GetRecipesByShopQuery req, CancellationToken ct)
|
||||
{
|
||||
var recipes = await _repo.GetByShopIdAsync(req.ShopId, ct);
|
||||
return recipes.Select(r => new RecipeDto(
|
||||
r.Id, r.ProductId, r.ShopId, r.Name, r.Instructions, r.PrepTimeMinutes, r.IsActive, r.CreatedAt,
|
||||
r.Ingredients.Select(i => new RecipeIngredientDto(i.Id, i.IngredientName, i.Quantity, i.Unit, i.CostPerUnit)).ToList()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,43 @@ public class KitchenController : ControllerBase
|
||||
var result = await _mediator.Send(new UpdateTicketStatusCommand(id, request.Status), ct);
|
||||
return Ok(new ApiResponse<bool> { Success = true, Data = result });
|
||||
}
|
||||
// ═══ RECIPE ENDPOINTS ═══
|
||||
|
||||
[HttpGet("recipes")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IEnumerable<RecipeDto>>), 200)]
|
||||
public async Task<ActionResult<ApiResponse<IEnumerable<RecipeDto>>>> GetRecipes(
|
||||
[FromQuery] Guid shopId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new GetRecipesByShopQuery(shopId), ct);
|
||||
return Ok(new ApiResponse<IEnumerable<RecipeDto>> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
[HttpPost("recipes")]
|
||||
[ProducesResponseType(typeof(ApiResponse<Guid>), 200)]
|
||||
public async Task<ActionResult<ApiResponse<Guid>>> CreateRecipe(
|
||||
[FromBody] CreateRecipeCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var id = await _mediator.Send(command, ct);
|
||||
return Ok(new ApiResponse<Guid> { Success = true, Data = id });
|
||||
}
|
||||
|
||||
[HttpPut("recipes/{id}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
|
||||
public async Task<ActionResult<ApiResponse<bool>>> UpdateRecipe(
|
||||
Guid id, [FromBody] UpdateRecipeCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var cmd = command with { RecipeId = id };
|
||||
var result = await _mediator.Send(cmd, ct);
|
||||
return Ok(new ApiResponse<bool> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
[HttpDelete("recipes/{id}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
|
||||
public async Task<ActionResult<ApiResponse<bool>>> DeleteRecipe(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new DeleteRecipeCommand(id), ct);
|
||||
return Ok(new ApiResponse<bool> { Success = true, Data = result });
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateStatusRequest(string Status);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using FnbEngine.Domain.SeedWork;
|
||||
|
||||
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
public interface IRecipeRepository : IRepository<Recipe>
|
||||
{
|
||||
Recipe Add(Recipe recipe);
|
||||
void Update(Recipe recipe);
|
||||
void Delete(Recipe recipe);
|
||||
Task<Recipe?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IEnumerable<Recipe>> GetByShopIdAsync(Guid shopId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using FnbEngine.Domain.SeedWork;
|
||||
|
||||
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
public class Recipe : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _shopId;
|
||||
private Guid _productId;
|
||||
private string _name = null!;
|
||||
private string? _instructions;
|
||||
private int _prepTimeMinutes;
|
||||
private bool _isActive;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
private readonly List<RecipeIngredient> _ingredients = new();
|
||||
|
||||
public Guid ShopId => _shopId;
|
||||
public Guid ProductId => _productId;
|
||||
public string Name => _name;
|
||||
public string? Instructions => _instructions;
|
||||
public int PrepTimeMinutes => _prepTimeMinutes;
|
||||
public bool IsActive => _isActive;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
public IReadOnlyList<RecipeIngredient> Ingredients => _ingredients.AsReadOnly();
|
||||
|
||||
protected Recipe() { }
|
||||
|
||||
public Recipe(Guid shopId, Guid productId, string name, string? instructions, int prepTimeMinutes)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
_shopId = shopId;
|
||||
_productId = productId;
|
||||
_name = name;
|
||||
_instructions = instructions;
|
||||
_prepTimeMinutes = prepTimeMinutes;
|
||||
_isActive = true;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Update(string name, string? instructions, int prepTimeMinutes)
|
||||
{
|
||||
_name = name;
|
||||
_instructions = instructions;
|
||||
_prepTimeMinutes = prepTimeMinutes;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void AddIngredient(string ingredientName, decimal quantity, string unit, decimal costPerUnit)
|
||||
{
|
||||
_ingredients.Add(new RecipeIngredient(Id, ingredientName, quantity, unit, costPerUnit));
|
||||
}
|
||||
|
||||
public void ClearIngredients()
|
||||
{
|
||||
_ingredients.Clear();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FnbEngine.Domain.SeedWork;
|
||||
|
||||
namespace FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
public class RecipeIngredient : Entity
|
||||
{
|
||||
private Guid _recipeId;
|
||||
private string _ingredientName = null!;
|
||||
private decimal _quantity;
|
||||
private string _unit = null!;
|
||||
private decimal _costPerUnit;
|
||||
|
||||
public Guid RecipeId => _recipeId;
|
||||
public string IngredientName => _ingredientName;
|
||||
public decimal Quantity => _quantity;
|
||||
public string Unit => _unit;
|
||||
public decimal CostPerUnit => _costPerUnit;
|
||||
|
||||
protected RecipeIngredient() { }
|
||||
|
||||
public RecipeIngredient(Guid recipeId, string ingredientName, decimal quantity, string unit, decimal costPerUnit)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
_recipeId = recipeId;
|
||||
_ingredientName = ingredientName;
|
||||
_quantity = quantity;
|
||||
_unit = unit;
|
||||
_costPerUnit = costPerUnit;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using FnbEngine.Domain.AggregatesModel.TableAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
using FnbEngine.Infrastructure.Idempotency;
|
||||
using FnbEngine.Infrastructure.Repositories;
|
||||
|
||||
@@ -52,6 +53,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITableRepository, TableRepository>();
|
||||
services.AddScoped<ISessionRepository, SessionRepository>();
|
||||
services.AddScoped<IKitchenTicketRepository, KitchenTicketRepository>();
|
||||
services.AddScoped<IRecipeRepository, RecipeRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
namespace FnbEngine.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class RecipeEntityTypeConfiguration : IEntityTypeConfiguration<Recipe>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Recipe> builder)
|
||||
{
|
||||
builder.ToTable("recipes");
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
builder.Property(r => r.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property(r => r.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired();
|
||||
builder.Property(r => r.Name).HasField("_name").HasColumnName("name").HasMaxLength(255).IsRequired();
|
||||
builder.Property(r => r.Instructions).HasField("_instructions").HasColumnName("instructions").HasMaxLength(2000);
|
||||
builder.Property(r => r.PrepTimeMinutes).HasField("_prepTimeMinutes").HasColumnName("prep_time_minutes");
|
||||
builder.Property(r => r.IsActive).HasField("_isActive").HasColumnName("is_active").HasDefaultValue(true);
|
||||
builder.Property(r => r.CreatedAt).HasField("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property(r => r.UpdatedAt).HasField("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
builder.HasMany(r => r.Ingredients)
|
||||
.WithOne()
|
||||
.HasForeignKey(i => i.RecipeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
var nav = builder.Metadata.FindNavigation(nameof(Recipe.Ingredients))!;
|
||||
nav.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.HasIndex(r => r.ShopId).HasDatabaseName("ix_recipes_shop_id");
|
||||
builder.HasIndex(r => r.ProductId).HasDatabaseName("ix_recipes_product_id");
|
||||
|
||||
builder.Ignore(r => r.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
|
||||
namespace FnbEngine.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class RecipeIngredientEntityTypeConfiguration : IEntityTypeConfiguration<RecipeIngredient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RecipeIngredient> builder)
|
||||
{
|
||||
builder.ToTable("recipe_ingredients");
|
||||
builder.HasKey(ri => ri.Id);
|
||||
builder.Property(ri => ri.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
builder.Property(ri => ri.RecipeId).HasField("_recipeId").HasColumnName("recipe_id").IsRequired();
|
||||
builder.Property(ri => ri.IngredientName).HasField("_ingredientName").HasColumnName("ingredient_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property(ri => ri.Quantity).HasField("_quantity").HasColumnName("quantity").HasColumnType("decimal(18,4)").IsRequired();
|
||||
builder.Property(ri => ri.Unit).HasField("_unit").HasColumnName("unit").HasMaxLength(50).IsRequired();
|
||||
builder.Property(ri => ri.CostPerUnit).HasField("_costPerUnit").HasColumnName("cost_per_unit").HasColumnType("decimal(18,2)");
|
||||
|
||||
builder.Ignore(ri => ri.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage;
|
||||
using FnbEngine.Domain.AggregatesModel.TableAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
using FnbEngine.Domain.SeedWork;
|
||||
using FnbEngine.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -17,6 +18,8 @@ public class FnbContext : DbContext, IUnitOfWork
|
||||
public DbSet<Table> Tables => Set<Table>();
|
||||
public DbSet<Session> Sessions => Set<Session>();
|
||||
public DbSet<KitchenTicket> KitchenTickets => Set<KitchenTicket>();
|
||||
public DbSet<Recipe> Recipes => Set<Recipe>();
|
||||
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
|
||||
public bool HasActiveTransaction => _currentTransaction != null;
|
||||
|
||||
@@ -31,6 +34,8 @@ public class FnbContext : DbContext, IUnitOfWork
|
||||
modelBuilder.ApplyConfiguration(new TableStatusEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new KitchenTicketEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RecipeEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RecipeIngredientEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
using FnbEngine.Domain.SeedWork;
|
||||
|
||||
namespace FnbEngine.Infrastructure.Repositories;
|
||||
|
||||
public class RecipeRepository : IRecipeRepository
|
||||
{
|
||||
private readonly FnbContext _context;
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public RecipeRepository(FnbContext context) => _context = context;
|
||||
|
||||
public Recipe Add(Recipe recipe) => _context.Recipes.Add(recipe).Entity;
|
||||
public void Update(Recipe recipe) => _context.Recipes.Update(recipe);
|
||||
public void Delete(Recipe recipe) => _context.Recipes.Remove(recipe);
|
||||
|
||||
public async Task<Recipe?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
await _context.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
|
||||
public async Task<IEnumerable<Recipe>> GetByShopIdAsync(Guid shopId, CancellationToken ct = default) =>
|
||||
await _context.Recipes.Include(r => r.Ingredients)
|
||||
.Where(r => r.ShopId == shopId && r.IsActive)
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -25,6 +25,18 @@ public class CreateMemberCommand : IRequest<CreateMemberResult>
|
||||
/// VI: Giới tính (tùy chọn).
|
||||
/// </summary>
|
||||
public string? Gender { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Display name (optional, stored in preferences).
|
||||
/// VI: Tên hiển thị (tùy chọn, lưu trong preferences).
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Phone number (optional, stored in preferences).
|
||||
/// VI: Số điện thoại (tùy chọn, lưu trong preferences).
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
@@ -34,6 +35,16 @@ public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, C
|
||||
// VI: Tạo member mới với gender (bắt đầu ở level 1, exp 0)
|
||||
var member = new Member(request.UserId, request.CountryCode, request.Gender);
|
||||
|
||||
// EN: Store name/phone in preferences JSON if provided
|
||||
// VI: Lưu name/phone vào preferences JSON nếu có
|
||||
if (!string.IsNullOrWhiteSpace(request.Name) || !string.IsNullOrWhiteSpace(request.Phone))
|
||||
{
|
||||
var prefs = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) prefs["name"] = request.Name;
|
||||
if (!string.IsNullOrWhiteSpace(request.Phone)) prefs["phone"] = request.Phone;
|
||||
member.UpdatePreferences(JsonSerializer.Serialize(prefs));
|
||||
}
|
||||
|
||||
_memberRepository.Add(member);
|
||||
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ public class MemberDto
|
||||
public string? Gender { get; set; }
|
||||
public string CountryCode { get; set; } = null!;
|
||||
public string? Preferences { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current member level (1, 2, 3...).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
@@ -29,6 +30,17 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
|
||||
|
||||
private static MemberDto MapToDto(Member member)
|
||||
{
|
||||
string? displayName = null, phone = null;
|
||||
if (!string.IsNullOrWhiteSpace(member.Preferences))
|
||||
{
|
||||
try
|
||||
{
|
||||
var prefs = JsonSerializer.Deserialize<JsonElement>(member.Preferences);
|
||||
if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString();
|
||||
if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return new MemberDto
|
||||
{
|
||||
Id = member.Id,
|
||||
@@ -36,6 +48,8 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
|
||||
Gender = member.Gender,
|
||||
CountryCode = member.CountryCode,
|
||||
Preferences = member.Preferences,
|
||||
DisplayName = displayName,
|
||||
Phone = phone,
|
||||
CurrentLevel = member.CurrentLevel,
|
||||
CurrentExp = member.CurrentExp,
|
||||
TotalExpEarned = member.TotalExpEarned,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using MembershipService.Domain.AggregatesModel.MemberAggregate;
|
||||
|
||||
@@ -24,18 +25,34 @@ public class GetMembersQueryHandler : IRequestHandler<GetMembersQuery, GetMember
|
||||
request.SearchTerm,
|
||||
cancellationToken);
|
||||
|
||||
var memberDtos = members.Select(m => new MemberDto
|
||||
var memberDtos = members.Select(m =>
|
||||
{
|
||||
Id = m.Id,
|
||||
UserId = m.UserId,
|
||||
Gender = m.Gender,
|
||||
CountryCode = m.CountryCode,
|
||||
Preferences = m.Preferences,
|
||||
CurrentLevel = m.CurrentLevel,
|
||||
CurrentExp = m.CurrentExp,
|
||||
TotalExpEarned = m.TotalExpEarned,
|
||||
CreatedAt = m.CreatedAt,
|
||||
UpdatedAt = m.UpdatedAt
|
||||
string? displayName = null, phone = null;
|
||||
if (!string.IsNullOrWhiteSpace(m.Preferences))
|
||||
{
|
||||
try
|
||||
{
|
||||
var prefs = JsonSerializer.Deserialize<JsonElement>(m.Preferences);
|
||||
if (prefs.TryGetProperty("name", out var n)) displayName = n.GetString();
|
||||
if (prefs.TryGetProperty("phone", out var p)) phone = p.GetString();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return new MemberDto
|
||||
{
|
||||
Id = m.Id,
|
||||
UserId = m.UserId,
|
||||
Gender = m.Gender,
|
||||
CountryCode = m.CountryCode,
|
||||
Preferences = m.Preferences,
|
||||
DisplayName = displayName,
|
||||
Phone = phone,
|
||||
CurrentLevel = m.CurrentLevel,
|
||||
CurrentExp = m.CurrentExp,
|
||||
TotalExpEarned = m.TotalExpEarned,
|
||||
CreatedAt = m.CreatedAt,
|
||||
UpdatedAt = m.UpdatedAt
|
||||
};
|
||||
});
|
||||
|
||||
return new GetMembersResult
|
||||
|
||||
@@ -12,7 +12,10 @@ namespace OrderService.API.Application.Commands;
|
||||
public record CreateOrderCommand(
|
||||
Guid ShopId,
|
||||
Guid? CustomerId,
|
||||
List<OrderItemRequest> Items
|
||||
List<OrderItemRequest> Items,
|
||||
decimal? DiscountAmount = null,
|
||||
string? DiscountType = null,
|
||||
string? DiscountReference = null
|
||||
) : IRequest<CreateOrderResult>;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -67,6 +67,13 @@ public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Cre
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Apply discount if provided
|
||||
// VI: Áp dụng giảm giá nếu có
|
||||
if (request.DiscountAmount is > 0)
|
||||
{
|
||||
order.ApplyDiscount(request.DiscountAmount.Value, request.DiscountType, request.DiscountReference);
|
||||
}
|
||||
|
||||
// EN: Mark order as validated after all items pass validation
|
||||
// VI: Đánh dấu order là validated sau khi tất cả items pass validation
|
||||
order.MarkAsValidated();
|
||||
|
||||
@@ -20,6 +20,10 @@ public class Order : Entity, IAggregateRoot
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
private decimal _discountAmount;
|
||||
private string? _discountType;
|
||||
private string? _discountReference;
|
||||
|
||||
private readonly List<OrderItem> _items = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +60,10 @@ public class Order : Entity, IAggregateRoot
|
||||
/// EN: Order items (line items).
|
||||
/// VI: Các items trong đơn hàng (dòng hàng).
|
||||
/// </summary>
|
||||
public decimal DiscountAmount => _discountAmount;
|
||||
public string? DiscountType => _discountType;
|
||||
public string? DiscountReference => _discountReference;
|
||||
|
||||
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
@@ -192,8 +200,23 @@ public class Order : Entity, IAggregateRoot
|
||||
AddDomainEvent(new OrderCancelledDomainEvent(this, reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Apply discount to order.
|
||||
/// VI: Áp dụng giảm giá cho đơn hàng.
|
||||
/// </summary>
|
||||
public void ApplyDiscount(decimal amount, string? type = null, string? reference = null)
|
||||
{
|
||||
if (amount < 0) throw new DomainException("Discount amount cannot be negative");
|
||||
_discountAmount = amount;
|
||||
_discountType = type;
|
||||
_discountReference = reference;
|
||||
RecalculateTotal();
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private void RecalculateTotal()
|
||||
{
|
||||
_totalAmount = _items.Sum(i => i.TotalPrice);
|
||||
_totalAmount = _items.Sum(i => i.TotalPrice) - _discountAmount;
|
||||
if (_totalAmount < 0) _totalAmount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,18 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
builder.Property<decimal>("_discountAmount")
|
||||
.HasColumnName("discount_amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
builder.Property<string?>("_discountType")
|
||||
.HasColumnName("discount_type")
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property<string?>("_discountReference")
|
||||
.HasColumnName("discount_reference")
|
||||
.HasMaxLength(255);
|
||||
|
||||
// EN: OrderItems collection
|
||||
// VI: Collection OrderItems
|
||||
builder.OwnsMany(o => o.Items, orderItems =>
|
||||
@@ -122,6 +134,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
builder.Ignore(o => o.CustomerId);
|
||||
builder.Ignore(o => o.Status);
|
||||
builder.Ignore(o => o.TotalAmount);
|
||||
builder.Ignore(o => o.DiscountAmount);
|
||||
builder.Ignore(o => o.DiscountType);
|
||||
builder.Ignore(o => o.DiscountReference);
|
||||
builder.Ignore(o => o.CreatedAt);
|
||||
builder.Ignore(o => o.UpdatedAt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user