feat(web-client-tpos): connect inventory and customer pages to real API data

This commit is contained in:
Ho Ngoc Hai
2026-02-28 05:48:54 +07:00
parent 5a81fee85a
commit e0d7567cf0
4 changed files with 345 additions and 188 deletions

View File

@@ -1,110 +1,137 @@
@page "/admin/customers"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Customer database — list of customers with search, filter by tier (VIP/Gold/Silver/New), cards with total spent + visits.
VI: Cơ sở dữ liệu khách hàng — danh sách khách hàng, tìm kiếm, lọc theo hạng, thẻ với tổng chi tiêu + lượt ghé.
Design: pencil-design/src/pages/tPOS/admin/customer-database.pen
EN: Customer database — real data from membership_service via BFF.
VI: Cơ sở dữ liệu khách hàng — dữ liệu thực từ membership_service qua BFF.
*@
<PageTitle>Khách hàng — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Khách hàng</h1>
<p class="admin-topbar__subtitle">Tất cả cửa hàng • @_customers.Count khách hàng</p>
<p class="admin-topbar__subtitle">@_members.Count thành viên</p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm khách hàng..." @bind="SearchQuery" />
<input type="text" placeholder="Tìm khách hàng..." @bind="_searchQuery" @bind:event="oninput" />
</div>
<button class="admin-btn-secondary">
<i data-lucide="download"></i>
<span>Xuất Excel</span>
</button>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("customers/create"))">
<i data-lucide="user-plus"></i>
<span>Thêm khách hàng</span>
</button>
</div>
</div>
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_activeTab == "all" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "all")">
Tất cả <span class="admin-tab__badge admin-tab__badge--active">@_customers.Count</span>
</button>
<button class="admin-tab @(_activeTab == "vip" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "vip")">
VIP <span class="admin-tab__badge">@_customers.Count(c => c.Tier == "VIP")</span>
</button>
<button class="admin-tab @(_activeTab == "gold" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "gold")">
Gold <span class="admin-tab__badge">@_customers.Count(c => c.Tier == "Gold")</span>
</button>
<button class="admin-tab @(_activeTab == "silver" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "silver")">
Silver <span class="admin-tab__badge">@_customers.Count(c => c.Tier == "Silver")</span>
</button>
<button class="admin-tab @(_activeTab == "new" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "new")">
Mới <span class="admin-tab__badge">@_customers.Count(c => c.Tier == "New")</span>
</button>
</div>
@* ═══ SUMMARY ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<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(139,92,246,0.1);">
<i data-lucide="users" style="color:#8B5CF6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_members.Count</span>
<span class="admin-stat-card__label">Tổng thành viên</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="crown" style="color:#FF5C00;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_members.Count(m => m.CurrentLevel >= 3)</span>
<span class="admin-stat-card__label">VIP</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="trending-up" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_members.Where(m => m.CreatedAt >= DateTime.UtcNow.AddDays(-30)).Count()</span>
<span class="admin-stat-card__label">Mới (30 ngày)</span>
</div>
</div>
</div>
@* ═══ CUSTOMER GRID ═══ *@
<div class="admin-content" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
@foreach (var c in FilteredCustomers)
@* ═══ MEMBERS TABLE ═══ *@
@if (IsLoading)
{
<div class="admin-staff-card" @onclick="@(() => NavigateTo($"customers/{c.Id}"))">
<div class="admin-staff-card__header">
<div class="admin-user-avatar" style="width:48px;height:48px;font-size:16px;background-color:@c.AvatarColor;">
@c.Initials
</div>
<div style="padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;background-color:@c.TierBg;color:@c.TierColor;">
@c.Tier
</div>
<div style="text-align:center;padding:48px;">
<div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div>
<p style="color:var(--admin-text-tertiary);font-size:14px;">Đang tải dữ liệu...</p>
</div>
}
else if (!_members.Any())
{
<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>
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-size:16px;font-weight:600;">@c.Name</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@c.Phone • @c.Email</span>
</div>
<div style="display:flex;gap:8px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@c.TotalSpent</div>
<div class="admin-store-stat__label">Tổng chi tiêu</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@c.Visits</div>
<div class="admin-store-stat__label">Lượt ghé</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@c.Points</div>
<div class="admin-store-stat__label">Điểm tích</div>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFFFFF);">Chưa có thành viên</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Khách hàng sẽ tự động trở thành thành viên khi mua hàng</p>
</div>
}
else
{
<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);">ID</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);">Giới tính</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Quốc gia</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>
</tr>
</thead>
<tbody>
@foreach (var m in _members)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;color:var(--admin-text-tertiary);">@m.Id.ToString()[..8]...</td>
<td style="padding:12px 16px;">
<span style="display:inline-flex;align-items:center;gap:6px;">
<span style="width:8px;height:8px;border-radius:50%;background:@GetLevelColor(m.CurrentLevel);"></span>
<span style="font-weight:600;font-size:14px;">@(m.LevelName ?? $"Level {m.CurrentLevel}")</span>
</span>
</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;font-weight:600;color:var(--admin-orange-primary);">@m.TotalExpEarned.ToString("N0")</td>
<td style="padding:12px 16px;font-size:14px;">@(m.Gender ?? "—")</td>
<td style="padding:12px 16px;font-size:14px;">@(m.CountryCode ?? "—")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@m.CreatedAt.ToString("dd/MM/yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private string _activeTab = "all";
private string _searchQuery = "";
private List<PosDataService.MemberInfo> _members = new();
private List<CustomerItem> FilteredCustomers => _activeTab == "all"
? _customers
: _customers.Where(c => c.Tier.ToLower() == (_activeTab == "new" ? "new" : _activeTab)).ToList();
private record CustomerItem(string Id, string Name, string Initials, string Phone, string Email, string Tier,
string TierColor, string TierBg, string AvatarColor, string TotalSpent, string Visits, string Points);
private readonly List<CustomerItem> _customers = new()
protected override async Task OnInitializedAsync()
{
new("1", "Nguyễn Thị Mai", "NM", "0901 234 567", "mai@email.com", "VIP", "#FF5C00", "rgba(255,92,0,0.125)", "#FF5C00", "12.5M", "48", "2,500"),
new("2", "Trần Văn Hùng", "TH", "0912 345 678", "hung@email.com", "VIP", "#FF5C00", "rgba(255,92,0,0.125)", "#8B5CF6", "9.8M", "35", "1,960"),
new("3", "Lê Hoàng Anh", "LA", "0923 456 789", "anh@email.com", "Gold", "#F59E0B", "rgba(245,158,11,0.125)", "#3B82F6", "5.2M", "22", "1,040"),
new("4", "Phạm Minh Châu", "PC", "0934 567 890", "chau@email.com", "Gold", "#F59E0B", "rgba(245,158,11,0.125)", "#22C55E", "4.7M", "19", "940"),
new("5", "Hoàng Thị Lan", "HL", "0945 678 901", "lan@email.com", "Silver", "#8B8B90", "rgba(139,139,144,0.125)", "#EC4899", "2.1M", "12", "420"),
new("6", "Võ Đức Mạnh", "VM", "0956 789 012", "manh@email.com", "Silver", "#8B8B90", "rgba(139,139,144,0.125)", "#06B6D4", "1.8M", "10", "360"),
new("7", "Đặng Thanh Tâm", "ĐT", "0967 890 123", "tam@email.com", "New", "#3B82F6", "rgba(59,130,246,0.125)", "#F59E0B", "350K", "3", "70"),
new("8", "Bùi Phương Uyên", "BU", "0978 901 234", "uyen@email.com", "New", "#3B82F6", "rgba(59,130,246,0.125)", "#3B82F6", "120K", "1", "24"),
IsLoading = true;
try { _members = await DataService.GetMembersAsync(); }
catch { }
finally { IsLoading = false; }
}
private static string GetLevelColor(int level) => level switch
{
1 => "#94A3B8",
2 => "#22C55E",
3 => "#3B82F6",
4 => "#F59E0B",
5 => "#FF5C00",
_ => "#8B5CF6"
};
}

View File

@@ -1,150 +1,171 @@
@page "/admin/inventory"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Inventory Dashboard — stock level KPIs, stock table with search, restock dates.
VI: Dashboard kho hàng — KPI tồn kho, bảng kho có tìm kiếm, ngày nhập hàng.
Design: pencil-design/src/pages/tPOS/admin/inventory-dashboard.pen
EN: Inventory dashboard — real data from inventory_service via BFF.
VI: Bảng điều khiển tồn kho — dữ liệu thực từ inventory_service qua BFF.
*@
<PageTitle>Kho hàng — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Kho hàng</h1>
<p class="admin-topbar__subtitle">@_stockItems.Length sản phẩm • Tất cả cửa hàng</p>
<p class="admin-topbar__subtitle">@_items.Count mặt hàng @(_selectedShopId.HasValue ? $"• {_shopName}" : "• Tất cả cửa hàng")</p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm sản phẩm..." @bind="SearchQuery" />
<input type="text" placeholder="Tìm sản phẩm..." @bind="_searchQuery" @bind:event="oninput" />
</div>
<button class="admin-btn-secondary">
<i data-lucide="download"></i>
<span>Xuất kho</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Nhập kho</span>
</button>
<select class="admin-select" style="min-width:160px;" @onchange="OnShopFilterChanged">
<option value="">Tất cả cửa hàng</option>
@foreach (var shop in _shops)
{
<option value="@shop.Id" selected="@(_selectedShopId == shop.Id)">@shop.Name</option>
}
</select>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@* KPI Row *@
<div class="admin-kpi-row">
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(59,130,246,0.125);">
<i data-lucide="package" style="color:#3B82F6;"></i>
</div>
@* ═══ SUMMARY CARDS ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<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(59,130,246,0.1);">
<i data-lucide="boxes" style="color:#3B82F6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_items.Count</span>
<span class="admin-stat-card__label">Tổng mặt hàng</span>
</div>
<div class="admin-kpi-card__value">1,248</div>
<div class="admin-kpi-card__label">Tổng sản phẩm</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(245,158,11,0.125);">
<i data-lucide="alert-triangle" style="color:#F59E0B;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--down">
<span>Cần nhập</span>
</div>
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
<i data-lucide="package-check" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_items.Count(i => i.Quantity > i.ReorderLevel)</span>
<span class="admin-stat-card__label">Đủ hàng</span>
</div>
<div class="admin-kpi-card__value">12</div>
<div class="admin-kpi-card__label">Sắp hết hàng</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(239,68,68,0.125);">
<i data-lucide="package-x" style="color:#EF4444;"></i>
</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">@_items.Count(i => i.Quantity <= i.ReorderLevel && i.Quantity > 0)</span>
<span class="admin-stat-card__label">Sắp hết</span>
</div>
<div class="admin-kpi-card__value">3</div>
<div class="admin-kpi-card__label">Hết hàng</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(34,197,94,0.125);">
<i data-lucide="wallet" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);">
<i data-lucide="package-x" style="color:#EF4444;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_items.Count(i => i.Quantity <= 0)</span>
<span class="admin-stat-card__label">Hết hàng</span>
</div>
<div class="admin-kpi-card__value">82.4M</div>
<div class="admin-kpi-card__label">Giá trị tồn kho</div>
</div>
</div>
@* Stock Table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="clipboard-list" style="color:var(--admin-orange-primary);"></i>
Danh sách tồn kho
</h3>
<a href="/admin/inventory/orders" class="admin-panel__action">Đặt hàng nhập →</a>
@* ═══ INVENTORY TABLE ═══ *@
@if (IsLoading)
{
<div style="text-align:center;padding:48px;">
<div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div>
<p style="color:var(--admin-text-tertiary);font-size:14px;">Đang tải tồn kho...</p>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Sản phẩm</th>
<th>Danh mục</th>
<th>Tồn kho</th>
<th>Đơn vị</th>
<th>Trạng thái</th>
<th>Nhập lần cuối</th>
<th style="text-align:right;">Giá trị</th>
</tr>
</thead>
<tbody>
@foreach (var item in _stockItems)
{
}
else if (!FilteredItems.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="boxes" style="width:36px;height:36px;color:#3B82F6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFFFFF);">Chưa có dữ liệu tồn kho</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Tồn kho sẽ tự động cập nhật khi có giao dịch mua/bán</p>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;">
<thead>
<tr>
<td style="font-weight:600;">@item.Name</td>
<td style="color:var(--admin-text-tertiary);">@item.Category</td>
<td>@item.Qty</td>
<td style="color:var(--admin-text-tertiary);">@item.Unit</td>
<td>
<div class="admin-status-badge @GetStockStatusClass(item.Status)" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
@item.Status
</div>
</td>
<td style="color:var(--admin-text-tertiary);">@item.LastRestock</td>
<td style="text-align:right;font-weight:600;">@item.Value</td>
<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);">Đặt trước</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);">Trạng thái</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var item in FilteredItems)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;font-size:14px;">@(item.ProductName ?? "N/A")</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--admin-text-tertiary);">@item.ReservedQuantity</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
<td style="padding:12px 16px;text-align:center;">
@{ var status = GetStockStatus(item); }
<span class="admin-status-badge @status.css" style="font-size:11px;padding:2px 10px;">
<span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>
@status.label
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
@code {
private string GetStockStatusClass(string status) => status switch
{
"Đủ hàng" => "admin-status-badge--online",
"Sắp hết" => "admin-status-badge--setup",
"Hết hàng" => "admin-status-badge--offline",
_ => ""
};
private string _searchQuery = "";
private Guid? _selectedShopId;
private string _shopName = "";
private List<PosDataService.InventoryItemInfo> _items = new();
private List<PosDataService.ShopInfo> _shops = new();
private record StockItem(string Name, string Category, string Qty, string Unit, string Status, string LastRestock, string Value);
private readonly StockItem[] _stockItems = new[]
private IEnumerable<PosDataService.InventoryItemInfo> FilteredItems => _items
.Where(i => string.IsNullOrEmpty(_searchQuery) ||
(i.ProductName ?? "").Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
{
new StockItem("Cà phê Arabica", "Nguyên liệu", "45", "kg", "Đủ hàng", "10/02/2025", "13.5M"),
new StockItem("Cà phê Robusta", "Nguyên liệu", "32", "kg", "Đủ hàng", "08/02/2025", "8.0M"),
new StockItem("Sữa tươi", "Nguyên liệu", "8", "thùng", "Sắp hết", "11/02/2025", "2.4M"),
new StockItem("Đường", "Nguyên liệu", "25", "kg", "Đủ hàng", "05/02/2025", "0.5M"),
new StockItem("Ly giấy 12oz", "Bao bì", "3", "thùng", "Sắp hết", "07/02/2025", "1.8M"),
new StockItem("Nắp ly", "Bao bì", "0", "thùng", "Hết hàng", "01/02/2025", "0"),
new StockItem("Trà Oolong", "Nguyên liệu", "18", "kg", "Đủ hàng", "09/02/2025", "5.4M"),
new StockItem("Bột matcha", "Nguyên liệu", "2", "kg", "Sắp hết", "06/02/2025", "3.2M"),
new StockItem("Ống hút giấy", "Bao bì", "0", "thùng", "Hết hàng", "28/01/2025", "0"),
new StockItem("Thịt bò Úc", "Thực phẩm", "15", "kg", "Đủ hàng", "11/02/2025", "12.0M"),
};
IsLoading = true;
try
{
_shops = await DataService.GetShopsAsync();
_items = await DataService.GetInventoryAsync(_selectedShopId);
}
catch { }
finally { IsLoading = false; }
}
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
var val = e.Value?.ToString();
_selectedShopId = Guid.TryParse(val, out var id) ? id : null;
_shopName = _shops.FirstOrDefault(s => s.Id == _selectedShopId)?.Name ?? "";
IsLoading = true;
try { _items = await DataService.GetInventoryAsync(_selectedShopId); }
catch { }
finally { IsLoading = false; }
}
private static (string css, string label) GetStockStatus(PosDataService.InventoryItemInfo item)
{
if (item.Quantity <= 0) return ("admin-status-badge--offline", "Hết hàng");
if (item.Quantity <= item.ReorderLevel) return ("admin-status-badge--warning", "Sắp hết");
return ("admin-status-badge--online", "Đủ hàng");
}
}

View File

@@ -78,4 +78,33 @@ public class PosDataService
var resp = await _http.DeleteAsync($"api/bff/products/{productId}");
return resp.IsSuccessStatusCode;
}
// ═══ INVENTORY METHODS ═══
public record InventoryItemInfo(Guid Id, Guid ProductId, Guid ShopId, int Quantity,
int ReorderLevel, int ReservedQuantity, DateTime? UpdatedAt, string? ProductName);
public async Task<List<InventoryItemInfo>> GetInventoryAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory";
return await _http.GetFromJsonAsync<List<InventoryItemInfo>>(url, _jsonOptions) ?? new();
}
// ═══ MEMBERSHIP/CUSTOMER METHODS ═══
public record MemberInfo(Guid Id, string? CountryCode, string? Gender, int CurrentExp,
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
public async Task<List<MemberInfo>> GetMembersAsync()
=> await _http.GetFromJsonAsync<List<MemberInfo>>("api/bff/members", _jsonOptions) ?? new();
// ═══ STAFF CREATE ═══
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
}

View File

@@ -227,7 +227,87 @@ public class BffDataController : ControllerBase
return NoContent();
}
// ═══ INVENTORY ENDPOINTS ═══
/// <summary>
/// EN: Get inventory items with product name (cross-DB join via subquery).
/// VI: Lấy danh sách tồn kho với tên sản phẩm.
/// </summary>
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory([FromQuery] Guid? shopId = null)
{
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var sql = @"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
FROM inventory_items";
if (shopId.HasValue)
sql += " WHERE shop_id = @ShopId";
sql += " ORDER BY quantity ASC";
var items = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
// EN: Enrich with product names from catalog_service
// VI: Bổ sung tên sản phẩm từ catalog_service
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
var products = (await catConn.QueryAsync<dynamic>("SELECT id, name FROM products")).ToList();
var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name);
var result = items.Select(i => new
{
i.id, i.product_id, i.shop_id, i.quantity, i.reorder_level, i.reserved_quantity, i.updated_at,
product_name = prodMap.TryGetValue((Guid)i.product_id, out var name) ? name : "Unknown"
});
return Ok(result);
}
// ═══ MEMBERSHIP/CUSTOMER ENDPOINTS ═══
/// <summary>
/// EN: Get all members (customers).
/// VI: Lấy danh sách thành viên (khách hàng).
/// </summary>
[HttpGet("members")]
public async Task<IActionResult> GetMembers()
{
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
var members = await conn.QueryAsync<dynamic>(
@"SELECT m.id, m.country_code, m.gender, m.current_exp, m.current_level,
m.total_exp_earned, m.created_at, m.preferences,
ml.name as level_name
FROM members m
LEFT JOIN membership_levels ml ON m.current_level = ml.level
WHERE m.is_deleted = false
ORDER BY m.created_at DESC");
return Ok(members);
}
// ═══ STAFF CREATE ENDPOINT ═══
/// <summary>
/// EN: Create a staff member.
/// VI: Tạo nhân viên mới.
/// </summary>
[HttpPost("staff")]
public async Task<IActionResult> CreateStaff([FromBody] CreateStaffRequest req)
{
var id = Guid.NewGuid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
// EN: Get default role and status IDs / VI: Lấy ID vai trò và trạng thái mặc định
var roleId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }) ;
if (roleId == 0) roleId = 1; // default to first role
var statusId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_statuses WHERE name = 'Active'");
if (statusId == 0) statusId = 1;
await conn.ExecuteAsync(
@"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, joined_at)
VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW())",
new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId });
return CreatedAtAction(nameof(GetStaff), new { }, new { id });
}
// EN: Request DTOs / VI: DTO yêu cầu
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
}