From d969f3dda8ce4f130aaac28874185e6b61295916 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 3 Mar 2026 11:27:34 +0700 Subject: [PATCH] feat(web-client-tpos): replace hardcoded POS data with API-driven endpoints --- .../Pages/Pos/Cafe/CafeDesktop.razor | 340 +++++++++++------- .../Services/PosDataService.cs | 23 ++ .../Controllers/BffDataController.cs | 164 +++++++++ 3 files changed, 405 insertions(+), 122 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor index 5c07166f..dc186171 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor @@ -1,13 +1,14 @@ @* EN: Café POS Desktop — Unified single-page with tabs: Sale, History, Dashboard. - Payment flow is inline (cart panel transforms to payment panel). + All data fetched from API — no hardcoded demo data. VI: POS Café Desktop — Trang đơn với tabs: Bán hàng, Lịch sử, Dashboard. - Thanh toán inline (panel giỏ hàng chuyển thành panel thanh toán). + Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng. *@ @page "/pos/{ShopId:guid}/cafe" @layout PosLayout @inherits PosBase -@inject WebClientTpos.Client.Services.PosDataService DataService +@using WebClientTpos.Client.Services +@inject PosDataService DataService @* ═══════════════ MAIN CONTENT AREA ═══════════════ *@
@@ -250,31 +251,57 @@ }
- @foreach (var order in FilteredOrders) + @if (_historyLoading) { -
-
- @order.Id - - @order.Status - -
-
- @order.Items -
-
@FormatPrice(order.Total)
-
@order.Time
-
@order.Method
-
-
+
+
Đang tải đơn hàng...
} - @if (!FilteredOrders.Any()) + else { -
- -
Không tìm thấy đơn hàng
-
+ @* EN: Session orders (created during this POS session) shown first *@ + @foreach (var order in FilteredSessionOrders) + { +
+
+ @order.Id + @order.Status +
+
+ @order.Items +
+
@FormatPrice(order.Total)
+
@order.Time
+
@order.Method
+
+
+
+ } + @* EN: API orders from DB *@ + @foreach (var order in FilteredApiOrders) + { +
+
+ @order.Id.ToString()[..8].ToUpper() + + @MapApiStatus(order.Status) + +
+
+
+
@FormatPrice(order.TotalAmount)
+
@order.CreatedAt.ToString("HH:mm dd/MM")
+
+
+
+ } + @if (!FilteredSessionOrders.Any() && !FilteredApiOrders.Any()) + { +
+ +
Chưa có đơn hàng nào
+
+ } }
@@ -290,61 +317,98 @@ -
- @foreach (var stat in _dashStats) - { + @if (_dashLoading) + { +
+
Đang tải thống kê...
+
+ } + else + { +
-
@stat.Label
-
@stat.Value
-
@stat.Sub
+
Doanh thu
+
@FormatPrice(_dashboard.Revenue)
+
TB @FormatPrice(_dashboard.AvgOrderValue)/đơn
+
+
+
Đơn hàng
+
@_dashboard.OrderCount
+
Hôm nay
+
+
+
Món bán ra
+
@_dashboard.ItemsSold
+
@(_dashboard.OrderCount > 0 ? $"{(double)_dashboard.ItemsSold / _dashboard.OrderCount:F1} món/đơn" : "—")
- } -
- -
- @* Popular items *@ -
-
Món bán chạy
- @foreach (var item in _dashPopular) - { - - }
- @* Payment breakdown + Hourly chart *@ -
-
Hình thức thanh toán
- @foreach (var p in _dashPayments) - { -
-
- @p.Method - @FormatPrice(p.Amount) -
-
-
-
-
- } - -
Doanh thu theo giờ
-
- @foreach (var h in _dashHourly) +
+ @* Popular items *@ +
+
Món bán chạy
+ @if (_dashboard.PopularItems.Any()) { -
-
- @h.Hour + @foreach (var item in _dashboard.PopularItems) + { + + } + } + else + { +
Chưa có dữ liệu
+ } +
+ + @* Payment breakdown + Hourly chart *@ +
+
Hình thức thanh toán
+ @if (_dashboard.PaymentBreakdown.Any()) + { + @foreach (var p in _dashboard.PaymentBreakdown) + { +
+
+ @p.Method + @FormatPrice(p.Amount) +
+
+
+
+
+ } + } + else + { +
Chưa có dữ liệu
+ } + +
Doanh thu theo giờ
+ @if (_dashboard.HourlyRevenue.Any()) + { +
+ @foreach (var h in _dashboard.HourlyRevenue) + { +
+
+ @h.Hour +
+ }
} + else + { +
Chưa có dữ liệu
+ }
-
+ }
break; } @@ -374,10 +438,16 @@ private enum PosTab { Sale, History, Dashboard } private PosTab _activeTab = PosTab.Sale; - private void SwitchTab(PosTab tab) + private async Task SwitchTab(PosTab tab) { if (_paymentStep != PayStep.None && _paymentStep != PayStep.Success) return; // Block tab switch during payment _activeTab = tab; + + // EN: Lazy-load data when switching tabs / VI: Load dữ liệu khi chuyển tab + if (tab == PosTab.History && !_historyLoaded) + await LoadHistoryAsync(); + else if (tab == PosTab.Dashboard && !_dashLoaded) + await LoadDashboardAsync(); } // ═══════════════ SALE TAB — Product & Cart ═══════════════ @@ -500,11 +570,12 @@ private void ConfirmPayment() { _lastOrderTotal = CartTotal; - _lastTransactionId = $"TXN-{DateTime.Now:yyyyMMdd}-{Random.Shared.Next(100, 999)}"; + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; - // EN: Save to history / VI: Lưu vào lịch sử + // EN: Save to session history (in-memory for this POS session) + // VI: Lưu vào lịch sử phiên (in-memory cho phiên POS hiện tại) var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; - _orderHistory.Insert(0, new OrderRecord( + _sessionOrders.Insert(0, new SessionOrder( _lastTransactionId, string.Join(", ", _cartItems.Select(i => $"{i.Name} x{i.Qty}")), _lastOrderTotal, @@ -513,6 +584,10 @@ "Hoàn thành" )); + // EN: Invalidate dashboard cache so next view refreshes + // VI: Xóa cache dashboard để refresh khi xem lại + _dashLoaded = false; + _paymentStep = PayStep.Success; } @@ -525,10 +600,20 @@ _customAmountInput = ""; } - // ═══════════════ HISTORY TAB ═══════════════ + // ═══════════════ HISTORY TAB — API-driven ═══════════════ private string _historySearch = ""; private string _historyFilter = "today"; - private OrderRecord? _selectedOrder; + private SessionOrder? _selectedOrder; + private bool _historyLoading; + private bool _historyLoaded; + + // EN: Session orders created during this POS session (in-memory, real-time) + // VI: Đơn hàng tạo trong phiên POS này (in-memory, real-time) + private readonly List _sessionOrders = new(); + + // EN: API orders fetched from DB + // VI: Đơn hàng từ API (DB) + private List _apiOrders = new(); private readonly List _historyFilters = new() { @@ -537,58 +622,73 @@ new("month", "30 ngày"), }; - // EN: Demo history data + real-time additions / VI: Dữ liệu lịch sử mẫu + thêm real-time - private readonly List _orderHistory = new() + private async Task LoadHistoryAsync() { - new("TXN-20260303-001", "Cà phê sữa đá x2, Bạc xỉu x1", 103_000, "10:15", "Tiền mặt", "Hoàn thành"), - new("TXN-20260303-002", "Cappuccino x1, Croissant bơ x2", 125_000, "10:02", "QR Code", "Hoàn thành"), - new("TXN-20260303-003", "Trà đào cam sả x3, Sinh tố bơ x1", 190_000, "09:48", "Thẻ", "Hoàn thành"), - new("TXN-20260303-004", "Matcha Latte x1", 59_000, "09:30", "Tiền mặt", "Hoàn thành"), - new("TXN-20260303-005", "Espresso x2, Latte x1", 145_000, "09:15", "Chuyển khoản", "Hoàn thành"), - new("TXN-20260303-006", "Cà phê đen đá x4", 116_000, "08:55", "Tiền mặt", "Hoàn thành"), - new("TXN-20260303-007", "Trà sen vàng x2, Bánh mì bơ x1", 133_000, "08:40", "QR Code", "Hoàn thành"), - new("TXN-20260303-008", "Sinh tố bơ x1", 55_000, "08:25", "Tiền mặt", "Hoàn trả"), - }; + _historyLoading = true; + StateHasChanged(); + try + { + _apiOrders = await DataService.GetOrdersAsync(ShopId); + } + catch { _apiOrders = new(); } + _historyLoaded = true; + _historyLoading = false; + StateHasChanged(); + } - private IEnumerable FilteredOrders => + private IEnumerable FilteredSessionOrders => string.IsNullOrWhiteSpace(_historySearch) - ? _orderHistory - : _orderHistory.Where(o => + ? _sessionOrders + : _sessionOrders.Where(o => o.Id.Contains(_historySearch, StringComparison.OrdinalIgnoreCase) || o.Items.Contains(_historySearch, StringComparison.OrdinalIgnoreCase)); - // ═══════════════ DASHBOARD TAB ═══════════════ - private readonly List _dashStats = new() + private IEnumerable FilteredApiOrders => + string.IsNullOrWhiteSpace(_historySearch) + ? _apiOrders + : _apiOrders.Where(o => + o.Id.ToString().Contains(_historySearch, StringComparison.OrdinalIgnoreCase) || + (o.Status?.Contains(_historySearch, StringComparison.OrdinalIgnoreCase) ?? false)); + + private static string MapApiStatus(string? status) => status switch { - new("Doanh thu", "8,450,000₫", "var(--pos-orange-primary)", "+12% so với hôm qua"), - new("Đơn hàng", "156", "var(--pos-success)", "TB 54,167₫/đơn"), - new("Khách hàng", "132", "var(--pos-info)", "18% khách quay lại"), - new("Món bán ra", "347", "var(--pos-warning)", "2.2 món/đơn"), + "Completed" or "Delivered" => "Hoàn thành", + "Cancelled" => "Đã hủy", + "Refunded" => "Hoàn trả", + "Processing" => "Đang xử lý", + "Pending" => "Chờ xác nhận", + _ => status ?? "—" }; - private readonly List _dashPopular = new() - { - new("Cà phê sữa đá", 52, 1_820_000), - new("Bạc xỉu", 38, 1_482_000), - new("Trà đào cam sả", 29, 1_305_000), - new("Cappuccino", 24, 1_320_000), - new("Sinh tố bơ", 18, 990_000), - }; + // ═══════════════ DASHBOARD TAB — API-driven ═══════════════ + private bool _dashLoading; + private bool _dashLoaded; + private PosDataService.PosDashboardInfo _dashboard = new(0, 0, 0, 0, new(), new(), new(), new()); - private readonly List _dashPayments = new() + private async Task LoadDashboardAsync() { - new("Tiền mặt", 4_650_000, 55, "var(--pos-success)"), - new("Chuyển khoản", 2_535_000, 30, "var(--pos-info)"), - new("Thẻ", 845_000, 10, "var(--pos-warning)"), - new("Ví điện tử", 420_000, 5, "var(--pos-orange-primary)"), - }; - - private readonly List _dashHourly = new() - { - new("7h", 30), new("8h", 65), new("9h", 85), new("10h", 55), - new("11h", 90), new("12h", 100), new("13h", 70), new("14h", 45), - new("15h", 60), new("16h", 75), new("17h", 50), new("18h", 20), - }; + _dashLoading = true; + StateHasChanged(); + try + { + var data = await DataService.GetPosDashboardAsync(ShopId); + // EN: Null-coalesce all lists to prevent NRE if API returns null + // VI: Null-coalesce tất cả list để tránh NRE nếu API trả null + _dashboard = new PosDataService.PosDashboardInfo( + data.Revenue, data.OrderCount, data.ItemsSold, data.AvgOrderValue, + data.PopularItems ?? new(), + data.PaymentBreakdown ?? new(), + data.HourlyRevenue ?? new(), + data.RecentOrders ?? new()); + } + catch + { + _dashboard = new(0, 0, 0, 0, new(), new(), new(), new()); + } + _dashLoaded = true; + _dashLoading = false; + StateHasChanged(); + } // ═══════════════ RECORDS ═══════════════ private record Product(string Name, decimal Price, string Category); @@ -598,10 +698,6 @@ public decimal Price { get; set; } = price; public int Qty { get; set; } = 1; } - private record OrderRecord(string Id, string Items, decimal Total, string Time, string Method, string Status); + private record SessionOrder(string Id, string Items, decimal Total, string Time, string Method, string Status); private record HistoryFilter(string Key, string Label); - private record DashStat(string Label, string Value, string Color, string Sub); - private record DashPopular(string Name, int Qty, decimal Revenue); - private record DashPayment(string Method, decimal Amount, int Pct, string Color); - private record DashHour(string Hour, int Pct); } 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 875f1f73..c89337ed 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 @@ -219,4 +219,27 @@ public class PosDataService public async Task> GetResourcesAsync(Guid shopId) { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/resources", _jsonOptions) ?? new(); } + + // ═══ POS DASHBOARD (real-time daily stats) ═══ + + // EN: POS dashboard response DTOs + // VI: DTOs cho response dashboard POS + public record PosDashboardInfo( + decimal Revenue, int OrderCount, int ItemsSold, decimal AvgOrderValue, + List PopularItems, + List PaymentBreakdown, + List HourlyRevenue, + List RecentOrders); + public record PopularItemInfo(string Name, int Qty, decimal Revenue); + public record PaymentBreakdownInfo(string Method, decimal Amount, int Pct); + public record HourlyRevenueInfo(string Hour, decimal Revenue, int Pct); + public record RecentOrderInfo(string Id, decimal Total, string Time, string Status, string Method); + + public async Task GetPosDashboardAsync(Guid shopId) + { + AttachToken(); + return await _http.GetFromJsonAsync( + $"api/bff/pos/dashboard?shopId={shopId}", _jsonOptions) + ?? new(0, 0, 0, 0, new(), new(), new(), new()); + } } 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 e6e73bd5..f454896f 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 @@ -734,6 +734,170 @@ public class BffDataController : ControllerBase return Ok(result); } + // ═══ POS DASHBOARD (real-time daily stats for POS screen) ═══ + + /// + /// EN: Get POS dashboard data — daily revenue, order count, popular items, payment breakdown, hourly chart. + /// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy, thanh toán, biểu đồ theo giờ. + /// + [HttpGet("pos/dashboard")] + public async Task GetPosDashboard([FromQuery] Guid? shopId = null) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(EmptyDashboard()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(EmptyDashboard()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(EmptyDashboard()); + + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + + // EN: Today's stats / VI: Thống kê hôm nay + decimal revenue = 0; int orderCount = 0; int itemsSold = 0; + List popularItems = new(); + List paymentBreakdown = new(); + List hourlyRevenue = new(); + List recentOrders = new(); + + try + { + await using var conn = new NpgsqlConnection(ConnStr("order_service")); + + // EN: Summary: total revenue + order count for today + // VI: Tổng kết: doanh thu + số đơn hôm nay + var summary = await conn.QueryFirstOrDefaultAsync( + @"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as total + FROM orders WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE", + new { ShopIds = targetShopIds.ToArray() }); + if (summary != null) + { + orderCount = (int)(long)summary.cnt; + revenue = (decimal)summary.total; + } + + // EN: Recent orders (last 50 today) + // VI: Đơn hàng gần đây (50 đơn cuối hôm nay) + var orders = await conn.QueryAsync( + @"SELECT o.id, o.total_amount, o.created_at, os.name as status, + COALESCE(o.payment_method, 'cash') as payment_method + FROM orders o + JOIN order_statuses os ON o.status_id = os.id + WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE + ORDER BY o.created_at DESC LIMIT 50", + new { ShopIds = targetShopIds.ToArray() }); + recentOrders = orders.Select(o => (object)new + { + id = ((Guid)o.id).ToString()[..8].ToUpper(), + total = (decimal)o.total_amount, + time = ((DateTime)o.created_at).ToString("HH:mm"), + status = (string)o.status, + method = MapPaymentMethod((string)o.payment_method) + }).ToList(); + + // EN: Payment breakdown (today) + // VI: Phân loại thanh toán (hôm nay) + try + { + var payments = await conn.QueryAsync( + @"SELECT COALESCE(payment_method, 'cash') as method, + SUM(total_amount) as total, COUNT(*) as cnt + FROM orders + WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE + GROUP BY COALESCE(payment_method, 'cash') + ORDER BY total DESC", + new { ShopIds = targetShopIds.ToArray() }); + var totalRev = payments.Sum(p => (decimal)p.total); + paymentBreakdown = payments.Select(p => (object)new + { + method = MapPaymentMethod((string)p.method), + amount = (decimal)p.total, + pct = totalRev > 0 ? (int)Math.Round((decimal)p.total / totalRev * 100) : 0 + }).ToList(); + } + catch { /* payment_method column may not exist */ } + + // EN: Hourly revenue (today, 6am–11pm) + // VI: Doanh thu theo giờ (hôm nay, 6h–23h) + try + { + var hourly = await conn.QueryAsync( + @"SELECT EXTRACT(HOUR FROM created_at)::int as hr, + SUM(total_amount) as total + FROM orders + WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE + GROUP BY 1 ORDER BY 1", + new { ShopIds = targetShopIds.ToArray() }); + var maxHr = hourly.Any() ? hourly.Max(h => (decimal)h.total) : 1; + for (int h = 6; h <= 22; h++) + { + var match = hourly.FirstOrDefault(x => (int)x.hr == h); + var val = match != null ? (decimal)match.total : 0; + hourlyRevenue.Add(new { hour = $"{h}h", revenue = val, pct = maxHr > 0 ? (int)(val / maxHr * 100) : 0 }); + } + } + catch { /* OK */ } + + // EN: Popular items (today — from order_items if exists) + // VI: Món bán chạy (hôm nay — từ order_items nếu có) + try + { + var popular = await conn.QueryAsync( + @"SELECT oi.product_name as name, SUM(oi.quantity) as qty, + SUM(oi.quantity * oi.unit_price) as revenue + FROM order_items oi + JOIN orders o ON oi.order_id = o.id + WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE + GROUP BY oi.product_name + ORDER BY qty DESC LIMIT 10", + new { ShopIds = targetShopIds.ToArray() }); + itemsSold = (int)popular.Sum(p => (long)p.qty); + popularItems = popular.Select(p => (object)new + { + name = (string)p.name, + qty = (int)(long)p.qty, + revenue = (decimal)p.revenue + }).ToList(); + } + catch { /* order_items table may not exist yet */ } + } + catch { /* order_service DB not available */ } + + return Ok(new + { + revenue, + orderCount, + itemsSold, + avgOrderValue = orderCount > 0 ? revenue / orderCount : 0, + popularItems, + paymentBreakdown, + hourlyRevenue, + recentOrders + }); + } + + private static object EmptyDashboard() => new + { + revenue = 0m, orderCount = 0, itemsSold = 0, avgOrderValue = 0m, + popularItems = Array.Empty(), + paymentBreakdown = Array.Empty(), + hourlyRevenue = Array.Empty(), + recentOrders = Array.Empty() + }; + + private static string MapPaymentMethod(string method) => method switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ", + "qr" => "QR Code", + "transfer" => "Chuyển khoản", + "ewallet" => "Ví điện tử", + _ => method + }; + // 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);