From e0d7567cf0840411924a45bf01d9e86826cca768 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 28 Feb 2026 05:48:54 +0700 Subject: [PATCH] feat(web-client-tpos): connect inventory and customer pages to real API data --- .../Admin/Customer/CustomerDatabase.razor | 181 +++++++------ .../Admin/Inventory/InventoryDashboard.razor | 241 ++++++++++-------- .../Services/PosDataService.cs | 29 +++ .../Controllers/BffDataController.cs | 82 +++++- 4 files changed, 345 insertions(+), 188 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Customer/CustomerDatabase.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Customer/CustomerDatabase.razor index 761e4a2f..3907c9df 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Customer/CustomerDatabase.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Customer/CustomerDatabase.razor @@ -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. *@ Khách hàng — GoodGo Admin -@* ═══ TOP BAR ═══ *@

Khách hàng

-

Tất cả cửa hàng • @_customers.Count khách hàng

+

@_members.Count thành viên

- -
-@* ═══ TABS ═══ *@ -
- - - - - -
+@* ═══ SUMMARY ═══ *@ +
+
+
+
+ +
+
+ @_members.Count + Tổng thành viên +
+
+
+
+ +
+
+ @_members.Count(m => m.CurrentLevel >= 3) + VIP +
+
+
+
+ +
+
+ @_members.Where(m => m.CreatedAt >= DateTime.UtcNow.AddDays(-30)).Count() + Mới (30 ngày) +
+
+
-@* ═══ CUSTOMER GRID ═══ *@ -
- @foreach (var c in FilteredCustomers) + @* ═══ MEMBERS TABLE ═══ *@ + @if (IsLoading) { -
-
-
- @c.Initials -
-
- @c.Tier -
+
+
+

Đang tải dữ liệu...

+
+ } + else if (!_members.Any()) + { +
+
+
-
- @c.Name - @c.Phone • @c.Email -
-
-
-
@c.TotalSpent
-
Tổng chi tiêu
-
-
-
@c.Visits
-
Lượt ghé
-
-
-
@c.Points
-
Điểm tích
-
+

Chưa có thành viên

+

Khách hàng sẽ tự động trở thành thành viên khi mua hàng

+
+ } + else + { +
+
+ + + + + + + + + + + + + @foreach (var m in _members) + { + + + + + + + + + } + +
IDCấp bậcEXPGiới tínhQuốc giaNgày tham gia
@m.Id.ToString()[..8]... + + + @(m.LevelName ?? $"Level {m.CurrentLevel}") + + @m.TotalExpEarned.ToString("N0")@(m.Gender ?? "—")@(m.CountryCode ?? "—")@m.CreatedAt.ToString("dd/MM/yyyy")
}
@code { - private string _activeTab = "all"; + private string _searchQuery = ""; + private List _members = new(); - private List 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 _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" }; } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Inventory/InventoryDashboard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Inventory/InventoryDashboard.razor index c1411a11..be0d8409 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Inventory/InventoryDashboard.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Inventory/InventoryDashboard.razor @@ -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. *@ Kho hàng — GoodGo Admin -@* ═══ TOP BAR ═══ *@

Kho hàng

-

@_stockItems.Length sản phẩm • Tất cả cửa hàng

+

@_items.Count mặt hàng @(_selectedShopId.HasValue ? $"• {_shopName}" : "• Tất cả cửa hàng")

- - +
-@* ═══ CONTENT ═══ *@ -
- - @* KPI Row *@ -
-
-
-
- -
+@* ═══ SUMMARY CARDS ═══ *@ +
+
+
+
+ +
+
+ @_items.Count + Tổng mặt hàng
-
1,248
-
Tổng sản phẩm
-
-
-
- -
-
- Cần nhập -
+
+
+ +
+
+ @_items.Count(i => i.Quantity > i.ReorderLevel) + Đủ hàng
-
12
-
Sắp hết hàng
-
-
-
- -
+
+
+ +
+
+ @_items.Count(i => i.Quantity <= i.ReorderLevel && i.Quantity > 0) + Sắp hết
-
3
-
Hết hàng
-
-
-
- -
+
+
+ +
+
+ @_items.Count(i => i.Quantity <= 0) + Hết hàng
-
82.4M
-
Giá trị tồn kho
- @* Stock Table *@ -
-
-

- - Danh sách tồn kho -

- Đặt hàng nhập → + @* ═══ INVENTORY TABLE ═══ *@ + @if (IsLoading) + { +
+
+

Đang tải tồn kho...

-
- - - - - - - - - - - - - - @foreach (var item in _stockItems) - { + } + else if (!FilteredItems.Any()) + { +
+
+ +
+

Chưa có dữ liệu tồn kho

+

Tồn kho sẽ tự động cập nhật khi có giao dịch mua/bán

+
+ } + else + { +
+
+
Sản phẩmDanh mụcTồn khoĐơn vịTrạng tháiNhập lần cuốiGiá trị
+ - - - - - - - + + + + + - } - -
@item.Name@item.Category@item.Qty@item.Unit -
- - @item.Status -
-
@item.LastRestock@item.ValueSản phẩmTồn khoĐặt trướcNgưỡngTrạng thái
+ + + @foreach (var item in FilteredItems) + { + + @(item.ProductName ?? "N/A") + @item.Quantity + @item.ReservedQuantity + @item.ReorderLevel + + @{ var status = GetStockStatus(item); } + + + @status.label + + + + } + + +
-
+ }
@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 _items = new(); + private List _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 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"); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index d1348427..b53cda24 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -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> GetInventoryAsync(Guid? shopId = null) + { + var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory"; + return await _http.GetFromJsonAsync>(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> GetMembersAsync() + => await _http.GetFromJsonAsync>("api/bff/members", _jsonOptions) ?? new(); + + // ═══ STAFF CREATE ═══ + + public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role); + + public async Task CreateStaffAsync(CreateStaffRequest req) + { + var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs index be962db9..1460bc76 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs @@ -227,7 +227,87 @@ public class BffDataController : ControllerBase return NoContent(); } + // ═══ INVENTORY ENDPOINTS ═══ + + /// + /// 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. + /// + [HttpGet("inventory")] + public async Task 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(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("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 ═══ + + /// + /// EN: Get all members (customers). + /// VI: Lấy danh sách thành viên (khách hàng). + /// + [HttpGet("members")] + public async Task GetMembers() + { + await using var conn = new NpgsqlConnection(ConnStr("membership_service")); + var members = await conn.QueryAsync( + @"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 ═══ + + /// + /// EN: Create a staff member. + /// VI: Tạo nhân viên mới. + /// + [HttpPost("staff")] + public async Task 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( + "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( + "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); } -