This commit is contained in:
Ho Ngoc Hai
2026-03-05 01:39:40 +07:00
parent df7eec1ec2
commit 629fed8a55
18 changed files with 586 additions and 124 deletions

View File

@@ -190,12 +190,25 @@
// ═══ MENU / PRODUCTS ═══
case "menu":
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"; _newProductCategoryId = ""; _formMessage = null; _showProductForm = !_showProductForm; })">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
Thêm sản phẩm
</button>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(FilteredProducts.Count) sản phẩm</h3>
<div style="display:flex;align-items:center;gap:8px;">
@if (_categories.Any())
{
<select @bind="_productCategoryFilter" @bind:after="@(() => _productPage = 1)" style="padding:6px 10px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;">
<option value="">Tất cả danh mục</option>
@foreach (var c in _categories) { <option value="@c.Id">@c.Name</option> }
</select>
}
<div style="display:flex;border:1px solid var(--admin-border-subtle);border-radius:8px;overflow:hidden;">
<button @onclick='() => _productView = "grid"' style="padding:6px 10px;border:none;cursor:pointer;background:@(_productView == "grid" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "grid" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="grid-3x3" style="width:14px;height:14px;"></i></button>
<button @onclick='() => _productView = "list"' style="padding:6px 10px;border:none;border-left:1px solid var(--admin-border-subtle);cursor:pointer;background:@(_productView == "list" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "list" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="list" style="width:14px;height:14px;"></i></button>
</div>
<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>
</div>
</div>
@if (_showProductForm)
{
@@ -238,10 +251,10 @@
{
@RenderEmpty("coffee", "#F59E0B", "Chưa có sản phẩm", "Thêm sản phẩm để bắt đầu bán hàng", "plus-circle", "Thêm sản phẩm", $"/admin/shop/{ShopId}/menu")
}
else
else if (_productView == "grid")
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
@foreach (var p in _products)
@foreach (var p in PagedProducts)
{
var typeColor = (p.Type ?? "") switch { "Service" => "#EC4899", "Physical" => "#3B82F6", _ => "#F59E0B" };
var typeLabel = (p.Type ?? "") switch { "Service" => "Dịch vụ", "Physical" => "Vật lý", _ => "Đồ uống" };
@@ -256,20 +269,67 @@
<div style="font-size:13px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(p.CategoryName ?? "—")</div>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:6px;background:@($"{typeColor}22");color:@typeColor;">@typeLabel</span>
<div style="font-weight:700;color:var(--admin-orange-primary);margin-top:8px;">@p.Price.ToString("N0")₫</div>
@if (p.Description?.Contains("variant:") == true || p.Description?.Contains("topping:") == true)
{
<div style="margin-top:6px;display:flex;gap:4px;justify-content:center;flex-wrap:wrap;">
@foreach (var tag in (p.Description ?? "").Split(',').Where(t => t.Trim().StartsWith("variant:") || t.Trim().StartsWith("topping:")).Take(3))
{
<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(59,130,246,0.1);color:#3B82F6;">@tag.Trim()</span>
}
</div>
}
</div>
</div>
}
</div>
}
else
{
@* LIST VIEW *@
<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);">Tên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Danh mục</th>
<th style="padding:12px 16px;text-align:center;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);">Giá</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var p in PagedProducts)
{
var typeColor = (p.Type ?? "") switch { "Service" => "#EC4899", "Physical" => "#3B82F6", _ => "#F59E0B" };
var typeLabel = (p.Type ?? "") switch { "Service" => "Dịch vụ", "Physical" => "Vật lý", _ => "Đồ uống" };
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@p.Name</td>
<td style="padding:12px 16px;color:var(--admin-text-secondary);font-size:13px;">@(p.CategoryName ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span style="font-size:11px;padding:2px 8px;border-radius:4px;background:@($"{typeColor}22");color:@typeColor;font-weight:600;">@typeLabel</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@p.Price.ToString("N0")₫</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => EditProduct(p))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteProduct(p.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@* ═══ PAGINATION ═══ *@
@if (ProductTotalPages > 1)
{
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin-top:16px;">
<button disabled="@(_productPage <= 1)" @onclick="@(() => _productPage--)"
style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-secondary);cursor:pointer;font-size:12px;">
<i data-lucide="chevron-left" style="width:14px;height:14px;"></i>
</button>
@for (var i = 1; i <= ProductTotalPages; i++)
{
var pg = i;
<button @onclick="@(() => _productPage = pg)"
style="min-width:32px;height:32px;border-radius:8px;border:1px solid @(pg == _productPage ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");background:@(pg == _productPage ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(pg == _productPage ? "#FFF" : "var(--admin-text-secondary)");cursor:pointer;font-size:12px;font-weight:600;">
@pg
</button>
}
<button disabled="@(_productPage >= ProductTotalPages)" @onclick="@(() => _productPage++)"
style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-secondary);cursor:pointer;font-size:12px;">
<i data-lucide="chevron-right" style="width:14px;height:14px;"></i>
</button>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">Trang @_productPage / @ProductTotalPages</span>
</div>
}
@* Categories Management *@
<div class="admin-panel" style="margin-top:20px;">
<div class="admin-panel__header">
@@ -341,7 +401,7 @@
{
<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);")">
@(_invSubTab == val ? "background:var(--admin-orange-primary);color:#FFF;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>
}
@@ -394,7 +454,7 @@
<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);">
<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);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
@@ -404,11 +464,11 @@
</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..." />
<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);color:var(--admin-text-primary);" 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..." />
<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);color:var(--admin-text-primary);" 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
@@ -423,7 +483,7 @@
<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);">
<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);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
@@ -433,11 +493,11 @@
</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..." />
<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);color:var(--admin-text-primary);" 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..." />
<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);color:var(--admin-text-primary);" 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
@@ -452,7 +512,7 @@
<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);">
<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);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
@@ -462,11 +522,11 @@
</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..." />
<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);color:var(--admin-text-primary);" 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..." />
<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);color:var(--admin-text-primary);" 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
@@ -708,9 +768,9 @@
@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><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:var(--admin-bg-elevated);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:var(--admin-bg-elevated);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:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
}
</div>
@@ -728,7 +788,16 @@
}
@if (!_staff.Any() && !_showStaffForm)
{
@RenderEmpty("users", "#8B5CF6", "Chưa có nhân viên", "Thêm nhân viên để quản lý cửa hàng", "user-plus", "Thêm nhân viên", $"/admin/shop/{ShopId}/staff")
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="users" style="width:36px;height:36px;color:#8B5CF6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có nhân viên</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Thêm nhân viên để quản lý cửa hàng</p>
<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; _showStaffForm = true; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i> Thêm nhân viên
</button>
</div>
}
else if (_staff.Any())
{
@@ -1608,6 +1677,22 @@
// ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══
case "promotions":
@* ─── Sub-tabs: Campaigns | Vouchers ─── *@
<div style="display:flex;gap:8px;margin-bottom:16px;">
@{ var promoTabs = new[] { ("campaigns", "Chiến dịch", "tag"), ("vouchers", "Mã voucher", "ticket") }; }
@foreach (var (tab, label, icon) in promoTabs)
{
var t = tab;
var isActive = _promoSubTab == t;
<button @onclick="@(() => SwitchPromoTab(t))"
class="@(isActive ? "admin-btn-primary" : "")"
style="padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);font-size:13px;display:inline-flex;align-items:center;gap:6px;cursor:pointer;@(isActive ? "background:var(--admin-orange-primary);color:#FFF;font-weight:700;border-color:var(--admin-orange-primary);" : "background:var(--admin-bg-elevated);color:var(--admin-text-secondary);font-weight:500;")">
<i data-lucide="@icon" style="width:14px;height:14px;"></i>@label
</button>
}
</div>
@if (_promoSubTab == "campaigns")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_campaigns.Count chiến dịch</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showCampaignForm = !_showCampaignForm; _editingCampaignId = null; _newCampaignName = ""; _newCampaignDesc = ""; _newCampaignValue = 0; _newCampaignVouchers = 0; _newCampaignDiscountType = "fixed"; _newCampaignStart = DateTime.Today; _newCampaignEnd = DateTime.Today.AddMonths(1); _campaignFormMessage = null; }'>
@@ -1652,7 +1737,7 @@
{
<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="tag" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Count</span><span class="admin-stat-card__label">Tổng chiến dịch</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="zap" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Count(c => c.StatusId == 1)</span><span class="admin-stat-card__label">Đang hoạt động</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="zap" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Count(c => c.Status == "Active")</span><span class="admin-stat-card__label">Đang hoạt động</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="ticket" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Sum(c => c.TotalVouchers)</span><span class="admin-stat-card__label">Tổng voucher</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="check-circle" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Sum(c => c.IssuedVouchers)</span><span class="admin-stat-card__label">Đã phát</span></div></div>
</div>
@@ -1688,6 +1773,55 @@
</tbody></table>
</div>
</div>
} @* end else *@
} @* end campaigns sub-tab *@
@if (_promoSubTab == "vouchers")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_vouchers.Count mã voucher</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="LoadVouchers">
<i data-lucide="refresh-cw" style="width:14px;height:14px;margin-right:4px;"></i>Làm mới
</button>
</div>
@if (!_vouchers.Any())
{
@RenderEmpty("ticket", "#EC4899", "Chưa có voucher", "Tạo chiến dịch để tự động sinh mã voucher", "tag", "Tạo chiến dịch")
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách mã voucher</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);">Mã</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Chiến dịch</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mệnh giá</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Còn lại</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tạo</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var v in _vouchers)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;font-family:monospace;letter-spacing:1px;color:var(--admin-orange-primary);">@v.Code</td>
<td style="padding:12px 16px;font-size:13px;">@(v.CampaignName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@FormatVND(v.FaceValue)</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(v.RemainingValue < v.FaceValue ? "#F59E0B" : "var(--admin-text-primary)");">@FormatVND(v.RemainingValue)</td>
<td style="padding:12px 16px;text-align:center;"><span style="font-size:11px;padding:2px 8px;border-radius:4px;background:@(GetVoucherStatusColor(v.Status));font-weight:600;">@GetVoucherStatusLabel(v.Status)</span></td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(v.CreatedAt?.ToLocalTime().ToString("dd/MM/yy") ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
@if (v.Status?.ToLower() == "available")
{
<button @onclick="@(() => RevokeVoucher(v.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;color:#EF4444;font-weight:600;cursor:pointer;" title="Thu hồi">Thu hồi</button>
}
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
}
break;
@@ -2062,48 +2196,94 @@
// ═══ C5: CA LÀM VIỆC / SHIFTS (Café) ═══
case "shifts":
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;margin-bottom:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="clock" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">3</span><span class="admin-stat-card__label">Ca hôm nay</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="user-check" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">5</span><span class="admin-stat-card__label">Đang làm việc</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-circle" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">1</span><span class="admin-stat-card__label">Vắng mặt</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="clock" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Count</span><span class="admin-stat-card__label">Ca đã phân</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="user-check" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count</span><span class="admin-stat-card__label">Nhân viên</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="calendar" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Select(s => s.DayOfWeek).Distinct().Count()</span><span class="admin-stat-card__label">Ngày có ca</span></div></div>
</div>
<div class="admin-panel">
@* ─── Add Schedule Form ─── *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title">📋 Lịch ca — Tuần này</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;"><i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Phân ca</button>
<h3 class="admin-panel__title">Phân ca làm việc</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="@(() => { _showScheduleForm = !_showScheduleForm; _schedFormMessage = null; })"><i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm ca</button>
</div>
@if (_showScheduleForm)
{
<div style="padding:16px;border-top:1px solid var(--admin-border-subtle);">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;align-items:end;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nhân viên</label>
<select @bind="_newSchedStaffIdStr" 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);font-size:13px;">
<option value="">-- Chọn NV --</option>
@foreach (var s in _staff) { <option value="@s.Id">@(s.EmployeeCode ?? s.Id.ToString()[..8])</option> }
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày</label>
<select @bind="_newSchedDay" 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);font-size:13px;">
<option value="1">Thứ 2</option><option value="2">Thứ 3</option><option value="3">Thứ 4</option>
<option value="4">Thứ 5</option><option value="5">Thứ 6</option><option value="6">Thứ 7</option><option value="0">Chủ nhật</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ bắt đầu</label><input type="text" @bind="_newSchedStart" placeholder="08:00" 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);font-size:13px;" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ kết thúc</label><input type="text" @bind="_newSchedEnd" placeholder="17:00" 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);font-size:13px;" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="AddSchedule">Lưu</button>
<button style="padding:6px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);font-size:12px;cursor:pointer;" @onclick="@(() => _showScheduleForm = false)">Hủy</button>
</div>
@if (_schedFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_schedFormSuccess ? "#22C55E" : "#EF4444");">@_schedFormMessage</div> }
</div>
}
</div>
@* ─── Weekly Grid ─── *@
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch ca — Tuần</h3></div>
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
<table class="admin-table" style="width:100%;min-width:700px;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);white-space:nowrap;">Nhân viên</th>
@foreach (var d in new[] { "T2", "T3", "T4", "T5", "T6", "T7", "CN" })
{
<th style="padding:12px 8px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);min-width:80px;">@d</th>
}
</tr></thead><tbody>
@foreach (var (name, shifts) in new[] {
("Nguyễn A", new[] { "S", "S", "C", "C", "", "S", "" }),
("Trần B", new[] { "C", "C", "S", "S", "C", "—", "—" }),
("Lê C", new[] { "S", "—", "S", "C", "S", "C", "S" }),
("Phạm D", new[] { "—", "S", "C", "—", "C", "S", "C" }),
("Hoàng E", new[] { "C", "C", "—", "S", "S", "—", "S" }) })
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;white-space:nowrap;">@name</td>
@foreach (var s in shifts)
{
var bg = s == "S" ? "rgba(59,130,246,0.12)" : s == "C" ? "rgba(168,85,247,0.12)" : "transparent";
var fg = s == "S" ? "#3B82F6" : s == "C" ? "#A855F7" : "var(--admin-text-tertiary)";
<td style="padding:8px;text-align:center;">
<span style="display:inline-block;width:36px;height:28px;line-height:28px;border-radius:6px;font-size:12px;font-weight:700;background:@bg;color:@fg;">@s</span>
</td>
}
</tr>
}
</tbody></table>
@if (!_staff.Any())
{
<div style="text-align:center;padding:40px 20px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có nhân viên. Thêm nhân viên trong mục <a href="/admin/shop/@ShopId/staff" style="color:var(--admin-orange-primary);">Nhân sự</a>.</div>
}
else
{
<table class="admin-table" style="width:100%;min-width:700px;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);white-space:nowrap;">Nhân viên</th>
@foreach (var d in new[] { "T2", "T3", "T4", "T5", "T6", "T7", "CN" })
{
<th style="padding:12px 8px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);min-width:80px;">@d</th>
}
</tr></thead><tbody>
@foreach (var emp in _staff)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;white-space:nowrap;">@(emp.EmployeeCode ?? emp.Id.ToString()[..8])</td>
@foreach (var dow in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var sched = _staffSchedules.FirstOrDefault(s => s.StaffId == emp.Id && s.DayOfWeek == dow);
if (sched != null)
{
var isMorning = sched.StartTime?.CompareTo("12:00") < 0;
var bg = isMorning ? "rgba(59,130,246,0.12)" : "rgba(168,85,247,0.12)";
var fg = isMorning ? "#3B82F6" : "#A855F7";
var label = $"{sched.StartTime?[..5]}";
<td style="padding:8px;text-align:center;">
<span title="@(sched.StartTime) - @(sched.EndTime)" style="display:inline-block;padding:2px 6px;border-radius:6px;font-size:11px;font-weight:700;background:@bg;color:@fg;">@label</span>
<button @onclick="@(() => DeleteScheduleItem(sched.Id))" style="background:none;border:none;cursor:pointer;margin-left:2px;" title="Xóa"><i data-lucide="x" style="width:10px;height:10px;color:#EF4444;"></i></button>
</td>
}
else
{
<td style="padding:8px;text-align:center;">
<span style="display:inline-block;width:36px;height:28px;line-height:28px;border-radius:6px;font-size:12px;font-weight:700;color:var(--admin-text-tertiary);">—</span>
</td>
}
}
</tr>
}
</tbody></table>
}
</div>
</div>
<div style="display:flex;gap:16px;margin-top:12px;">
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(59,130,246,0.25);"></span> S = Sáng (7:00-14:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(168,85,247,0.25);"></span> C = Chiều (14:00-22:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(59,130,246,0.25);"></span> Sáng (&lt;12:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(168,85,247,0.25);"></span> Chiều (12:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:transparent;border:1px solid var(--admin-border-subtle);"></span> — = Nghỉ</div>
</div>
break;
@@ -2132,7 +2312,7 @@
@code {
[Parameter] public string ShopId { get; set; } = "";
[Parameter] public string Section { get; set; } = "";
[CascadingParameter] public AdminLayout? Layout { get; set; }
[CascadingParameter] public AdminLayout? AdminLayoutRef { get; set; }
private string _shopName = "";
private string _verticalLabel = "";
@@ -2171,6 +2351,17 @@
private string _newProductCategoryId = "";
private string? _formMessage;
private bool _formSuccess;
// Product pagination + view state
private int _productPage = 1;
private int _productPageSize = 20;
private string _productView = "grid"; // grid | list
private string _productCategoryFilter = "";
private int ProductTotalPages => Math.Max(1, (int)Math.Ceiling((double)FilteredProducts.Count / _productPageSize));
private List<PosDataService.AdminProductInfo> FilteredProducts =>
string.IsNullOrEmpty(_productCategoryFilter) ? _products :
_products.Where(p => (p.CategoryId?.ToString() ?? "") == _productCategoryFilter).ToList();
private List<PosDataService.AdminProductInfo> PagedProducts =>
FilteredProducts.Skip((_productPage - 1) * _productPageSize).Take(_productPageSize).ToList();
// Staff form state
private bool _showStaffForm;
private Guid? _editingStaffId;
@@ -2302,6 +2493,10 @@
private string _newSchedEnd = "17:00";
private string? _schedFormMessage;
private bool _schedFormSuccess;
// Voucher management state
private string _promoSubTab = "campaigns"; // campaigns | vouchers
private List<PosDataService.AdminVoucherInfo> _vouchers = new();
private Guid? _voucherCampaignFilter;
// Recipes state
private List<PosDataService.RecipeInfo> _recipes = new();
private bool _showRecipeForm;
@@ -2346,7 +2541,7 @@
_verticalLabel = ShopSidebarConfig.GetVerticalLabel(shop.Category);
_posVertical = MapCategoryToVertical(shop.Category);
_merchantId = shop.MerchantId;
Layout?.SetShopContext(ShopId, _shopName, shop.Category);
AdminLayoutRef?.SetShopContext(ShopId, _shopName, shop.Category);
}
}
@@ -2412,6 +2607,7 @@
case "promotions":
_campaigns = await DataService.GetCampaignsAsync();
break;
case "shifts":
case "schedule":
_staffSchedules = await DataService.GetStaffSchedulesAsync(_shopGuid);
_staff = await DataService.GetStaffAsync();
@@ -2514,6 +2710,40 @@
private static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
private async Task SwitchPromoTab(string tab)
{
_promoSubTab = tab;
if (tab == "vouchers" && !_vouchers.Any())
{
_vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
StateHasChanged();
}
}
private async Task LoadVouchers()
{
_vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
StateHasChanged();
}
private async Task RevokeVoucher(Guid voucherId)
{
var ok = await DataService.RevokeVoucherAsync(voucherId);
if (ok) _vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
}
private static string GetVoucherStatusLabel(string? status) => (status ?? "").ToLower() switch
{
"available" => "Chưa nhận", "claimed" => "Đã nhận", "redeemed" => "Đã dùng",
"revoked" => "Thu hồi", "expired" => "Hết hạn", _ => status ?? "—"
};
private static string GetVoucherStatusColor(string? status) => (status ?? "").ToLower() switch
{
"available" => "#F59E0B", "claimed" => "#3B82F6", "redeemed" => "#22C55E",
"revoked" => "#EF4444", "expired" => "#888", _ => "#888"
};
// EN: Reusable empty state renderer with dynamic CTA href / VI: Renderer trạng thái trống với CTA href động
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
@@ -2619,7 +2849,11 @@
private async Task AddStaff()
{
_staffFormMessage = null;
if (string.IsNullOrWhiteSpace(_newStaffCode) || !_merchantId.HasValue)
if (!_merchantId.HasValue)
{
_staffFormMessage = "Không tìm thấy thông tin merchant. Vui lòng tải lại trang."; _staffFormSuccess = false; return;
}
if (string.IsNullOrWhiteSpace(_newStaffCode))
{
_staffFormMessage = "Vui lòng nhập mã nhân viên."; _staffFormSuccess = false; return;
}
@@ -2754,7 +2988,7 @@
}
// ═══ 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 ViewOrderDetail(Guid orderId) { if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; } _selectedOrderId = orderId; try { _orderDetail = await DataService.GetOrderDetailAsync(orderId, _shopGuid); } catch { _orderDetail = null; } }
private async Task CancelOrderItem(Guid orderId) { var ok = await DataService.CancelOrderAsync(orderId); if (ok) { _selectedOrderId = null; _orderDetail = null; await LoadData(); } }
// ═══ SHOP EDIT ═══

View File

@@ -334,10 +334,21 @@
case PosTab.Dashboard:
@* ═══ DASHBOARD TAB ═══ *@
<div class="pos-dashboard" style="width:100%;">
<div class="pos-dashboard__header">
<div class="pos-dashboard__header" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div class="pos-dashboard__title">Dashboard bán hàng</div>
<div class="pos-dashboard__subtitle">@DateTime.Now.ToString("dd/MM/yyyy") — Hôm nay</div>
<div class="pos-dashboard__subtitle">@(_dashPeriod switch { "7d" => "7 ngày gần nhất", "30d" => "30 ngày gần nhất", _ => DateTime.Now.ToString("dd/MM/yyyy") + " — Hôm nay" })</div>
</div>
<div style="display:flex;gap:4px;background:var(--pos-bg-secondary, rgba(255,255,255,0.05));border-radius:8px;padding:3px;">
@foreach (var (label, val) in new[] { ("Hôm nay", "today"), ("7 ngày", "7d"), ("30 ngày", "30d") })
{
<button @onclick="@(() => SwitchDashPeriod(val))"
style="padding:6px 12px;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;border:none;
background:@(_dashPeriod == val ? "var(--pos-orange-primary, #FF5C00)" : "transparent");
color:@(_dashPeriod == val ? "#FFF" : "var(--pos-text-tertiary, #888)");">
@label
</button>
}
</div>
</div>
@@ -831,15 +842,23 @@
// ═══════════════ DASHBOARD TAB — API-driven ═══════════════
private bool _dashLoading;
private bool _dashLoaded;
private string _dashPeriod = "today";
private PosDataService.PosDashboardInfo _dashboard = new(0, 0, 0, 0, new(), new(), new(), new());
private async Task SwitchDashPeriod(string period)
{
_dashPeriod = period;
_dashLoaded = false;
await LoadDashboardAsync();
}
private async Task LoadDashboardAsync()
{
_dashLoading = true;
StateHasChanged();
try
{
var data = await DataService.GetPosDashboardAsync(ShopId);
var data = await DataService.GetPosDashboardAsync(ShopId, _dashPeriod);
var payments = data.PaymentBreakdown ?? new();
var hourly = data.HourlyRevenue ?? new();

View File

@@ -299,11 +299,16 @@ public class PosDataService
// EN: Campaign record and request DTOs for CRUD operations
// VI: Record chiến dịch và DTO yêu cầu cho CRUD
public record CampaignInfo(Guid Id, string Name, string? Description, decimal FaceValue,
int TotalVouchers, int IssuedVouchers, DateTime? StartDate, DateTime? EndDate, int StatusId, DateTime CreatedAt);
int TotalVouchers, int IssuedVouchers, DateTime? StartDate, DateTime? EndDate, string? Status, DateTime CreatedAt);
public record PaginatedCampaignResponse(List<CampaignInfo>? Items, int TotalCount, int PageNumber, int PageSize, int TotalPages);
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
public async Task<List<CampaignInfo>> GetCampaignsAsync()
=> await GetListFromApiAsync<CampaignInfo>("api/bff/campaigns");
{
AttachToken();
var resp = await _http.GetFromJsonAsync<PaginatedCampaignResponse>("api/bff/campaigns", _jsonOptions);
return resp?.Items ?? new();
}
public async Task<bool> CreateCampaignAsync(CreateCampaignRequest req)
{
@@ -449,11 +454,11 @@ public class PosDataService
}
public record RecentOrderInfo(Guid Id, decimal TotalAmount, string Status, int ItemCount, DateTime CreatedAt);
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId)
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId, string? period = "today")
{
AttachToken();
return await _http.GetFromJsonAsync<PosDashboardInfo>(
$"api/bff/pos/dashboard?shopId={shopId}", _jsonOptions)
$"api/bff/pos/dashboard?shopId={shopId}&period={period ?? "today"}", _jsonOptions)
?? new(0, 0, 0, 0, new(), new(), new(), new());
}
@@ -510,10 +515,11 @@ public class PosDataService
public record OrderItemInfo(Guid Id, string? ProductName, int Quantity, decimal UnitPrice, decimal Subtotal);
public record OrderDetailResponse(OrderDetailInfo? Order, List<OrderItemInfo>? Items);
public async Task<OrderDetailResponse?> GetOrderDetailAsync(Guid orderId)
public async Task<OrderDetailResponse?> GetOrderDetailAsync(Guid orderId, Guid? shopId = null)
{
AttachToken();
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}", _jsonOptions);
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}{qs}", _jsonOptions);
}
public async Task<bool> CancelOrderAsync(Guid orderId)
@@ -684,6 +690,27 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
// ═══ ADMIN VOUCHER MANAGEMENT ═══
public record AdminVoucherInfo(Guid Id, Guid CampaignId, string? CampaignName, string? Code,
Guid? OwnerId, string? OwnerEmail, decimal FaceValue, decimal RemainingValue, string? Status,
DateTime? ClaimedAt, DateTime? ExpiresAt, DateTime? RedeemedAt, DateTime? CreatedAt);
public record PaginatedVoucherResponse(List<AdminVoucherInfo>? Items, int TotalCount, int PageNumber, int PageSize, int TotalPages);
public async Task<List<AdminVoucherInfo>> GetAdminVouchersAsync(Guid? campaignId = null, string? status = null, int pageSize = 50)
{
AttachToken();
var qs = $"pageSize={pageSize}";
if (campaignId.HasValue) qs += $"&campaignId={campaignId}";
if (!string.IsNullOrEmpty(status)) qs += $"&status={status}";
var resp = await _http.GetFromJsonAsync<PaginatedVoucherResponse>($"api/bff/vouchers/list?{qs}", _jsonOptions);
return resp?.Items ?? new();
}
public async Task<bool> RevokeVoucherAsync(Guid voucherId)
{ AttachToken(); var r = await _http.PostAsync($"api/bff/vouchers/{voucherId}/revoke", null); return r.IsSuccessStatusCode; }
// ═══ CAMPAIGN ACTIONS ═══
public async Task<bool> ActivateCampaignAsync(Guid campaignId)

View File

@@ -24,9 +24,12 @@ public class CatalogController : ControllerBase
/// VI: Lấy sản phẩm thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("products")]
public Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null)
public Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null, [FromQuery] bool? isActive = true)
{
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
var qsList = new List<string>();
if (shopId.HasValue) qsList.Add($"shopId={shopId}");
if (isActive.HasValue) qsList.Add($"isActive={isActive.Value.ToString().ToLower()}");
var qs = qsList.Any() ? "?" + string.Join("&", qsList) : "";
return _catalog.GetAsync($"/api/v1/products{qs}").ProxyAsync();
}

View File

@@ -134,16 +134,43 @@ public class FinancialController : ControllerBase
/// VI: Lấy danh sách chiến dịch của merchant hiện tại.
/// </summary>
[HttpGet("campaigns")]
public Task<IActionResult> GetCampaigns() =>
_promotion.GetAsync("/api/v1/campaigns").ProxyAsync();
public Task<IActionResult> GetCampaigns([FromQuery] int pageSize = 100) =>
_promotion.GetAsync($"/api/v1/admin/campaigns?pageSize={pageSize}").ProxyAsync();
/// <summary>
/// EN: Create a campaign.
/// VI: Tạo chiến dịch.
/// EN: Create a campaign. Enriches with default backing asset and acquisition fields.
/// VI: Tạo chiến dịch. Bổ sung thông tin backing asset và acquisition mặc định.
/// </summary>
[HttpPost("campaigns")]
public Task<IActionResult> CreateCampaign([FromBody] JsonElement body) =>
_promotion.PostAsJsonAsync("/api/v1/campaigns", body).ProxyAsync();
public async Task<IActionResult> CreateCampaign([FromBody] JsonElement body)
{
// EN: Enrich with required fields using raw JSON manipulation
// VI: Bổ sung các trường bắt buộc bằng thao tác JSON trực tiếp
var rawJson = body.GetRawText();
var defaults = new Dictionary<string, string>
{
["backingAssetType"] = "\"currency\"",
["backingAssetCode"] = "\"VND\"",
["acquisitionType"] = "\"free\"",
["acquisitionPrice"] = "0",
["merchantId"] = $"\"{Guid.Empty}\"",
["merchantWalletId"] = $"\"{Guid.Empty}\""
};
foreach (var (key, val) in defaults)
{
if (!rawJson.Contains($"\"{key}\""))
rawJson = rawJson.TrimEnd('}') + $",\"{key}\":{val}}}";
}
var content = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json");
var resp = await _promotion.PostAsync("/api/v1/campaigns", content);
var respContent = await resp.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)resp.StatusCode,
Content = respContent,
ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
/// <summary>
/// EN: Update a campaign.
@@ -183,6 +210,35 @@ public class FinancialController : ControllerBase
public Task<IActionResult> RedeemVoucher([FromBody] JsonElement body) =>
_promotion.PostAsJsonAsync("/api/v1/vouchers/redeem", body).ProxyAsync();
// ═══ ADMIN VOUCHER MANAGEMENT ═══
/// <summary>
/// EN: List vouchers with optional filters (campaignId, status, codeSearch).
/// VI: Liệt kê voucher với bộ lọc tùy chọn (campaignId, status, codeSearch).
/// </summary>
[HttpGet("vouchers/list")]
public Task<IActionResult> GetAdminVouchers(
[FromQuery] Guid? campaignId = null,
[FromQuery] string? status = null,
[FromQuery] string? codeSearch = null,
[FromQuery] int pageSize = 50,
[FromQuery] int page = 1)
{
var qs = new List<string> { $"pageSize={pageSize}", $"pageNumber={page}" };
if (campaignId.HasValue) qs.Add($"campaignId={campaignId}");
if (!string.IsNullOrEmpty(status)) qs.Add($"status={status}");
if (!string.IsNullOrEmpty(codeSearch)) qs.Add($"codeSearch={Uri.EscapeDataString(codeSearch)}");
return _promotion.GetAsync($"/api/v1/admin/vouchers?{string.Join("&", qs)}").ProxyAsync();
}
/// <summary>
/// EN: Revoke a voucher.
/// VI: Thu hồi voucher.
/// </summary>
[HttpPost("vouchers/{voucherId:guid}/revoke")]
public Task<IActionResult> RevokeVoucher(Guid voucherId) =>
_promotion.PostAsJsonAsync($"/api/v1/admin/vouchers/{voucherId}/revoke", new { reason = "Revoked by admin" }).ProxyAsync();
/// <summary>
/// EN: Activate a campaign.
/// VI: Kích hoạt chiến dịch.

View File

@@ -44,8 +44,50 @@ public class MembershipController : ControllerBase
/// VI: Tạo thành viên.
/// </summary>
[HttpPost("members")]
public Task<IActionResult> CreateMember([FromBody] JsonElement body) =>
_membership.PostAsJsonAsync("/api/v1/members", body).ProxyAsync();
public async Task<IActionResult> CreateMember([FromBody] JsonElement body)
{
// EN: Extract userId from JWT sub claim and inject into request body
// VI: Trích userId từ JWT sub claim và thêm vào request body
var rawJson = body.GetRawText();
var userId = ExtractSubFromJwt();
if (!string.IsNullOrEmpty(userId) && !rawJson.Contains("\"userId\""))
{
// EN: Insert userId field into JSON body
// VI: Chèn trường userId vào JSON body
rawJson = rawJson.TrimEnd('}') + $",\"userId\":\"{userId}\"}}";
}
var httpContent = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json");
var resp = await _membership.PostAsync("/api/v1/members", httpContent);
var respContent = await resp.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)resp.StatusCode,
Content = respContent,
ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
private string? ExtractSubFromJwt()
{
var authHeader = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) return null;
try
{
var parts = authHeader["Bearer ".Length..].Split('.');
if (parts.Length != 3) return null;
var payload = parts[1];
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
payload = payload.Replace('-', '+').Replace('_', '/');
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null;
}
catch { return null; }
}
/// <summary>
/// EN: Update a member.

View File

@@ -54,8 +54,51 @@ public class OrderController : ControllerBase
/// VI: Lấy chi tiết đơn hàng kèm items.
/// </summary>
[HttpGet("orders/{orderId:guid}")]
public Task<IActionResult> GetOrderDetail(Guid orderId) =>
_order.GetAsync($"/api/v1/orders/{orderId}").ProxyAsync();
public async Task<IActionResult> GetOrderDetail(Guid orderId, [FromQuery] Guid? shopId = null)
{
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
var resp = await _order.GetAsync($"/api/v1/orders/{orderId}{qs}");
if (!resp.IsSuccessStatusCode)
return StatusCode((int)resp.StatusCode, await resp.Content.ReadAsStringAsync());
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// EN: Transform flat OrderDto {Id,ShopId,...,Items} into {Order:{...}, Items:[...]}
// VI: Chuyển đổi OrderDto phẳng thành {Order:{...}, Items:[...]}
var order = new Dictionary<string, object?>();
JsonElement items = default;
foreach (var prop in root.EnumerateObject())
{
if (prop.Name.Equals("items", StringComparison.OrdinalIgnoreCase))
{
items = prop.Value;
}
else
{
order[prop.Name] = System.Text.Json.JsonSerializer.Deserialize<object>(prop.Value.GetRawText());
}
}
var itemsList = new List<object>();
if (items.ValueKind == JsonValueKind.Array)
{
foreach (var item in items.EnumerateArray())
{
var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText())!;
// EN: Add subtotal field for frontend compatibility
// VI: Thêm trường subtotal cho tương thích frontend
if (item.TryGetProperty("quantity", out var qty) && item.TryGetProperty("unitPrice", out var up))
{
dict["subtotal"] = qty.GetInt32() * up.GetDecimal();
}
itemsList.Add(dict);
}
}
return Ok(new { order, items = itemsList });
}
/// <summary>
/// EN: Cancel an order.
@@ -180,9 +223,12 @@ public class OrderController : ControllerBase
/// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy.
/// </summary>
[HttpGet("pos/dashboard")]
public Task<IActionResult> GetPosDashboard([FromQuery] Guid? shopId = null)
public Task<IActionResult> GetPosDashboard([FromQuery] Guid? shopId = null, [FromQuery] string? period = "today")
{
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
var qsList = new List<string>();
if (shopId.HasValue) qsList.Add($"shopId={shopId}");
if (!string.IsNullOrEmpty(period)) qsList.Add($"period={period}");
var qs = qsList.Any() ? "?" + string.Join("&", qsList) : "";
return _order.GetAsync($"/api/v1/orders/dashboard{qs}").ProxyAsync();
}
}