From 33047afa752a6b3e863f2abba1175cf549f955a8 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 3 Mar 2026 21:36:52 +0700 Subject: [PATCH] =?UTF-8?q?feat(web-client-tpos):=20Phase=20C-E=20?= =?UTF-8?q?=E2=80=94=20shop=20settings,=20top=20products=20report,=20enhan?= =?UTF-8?q?ced=20placeholders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BFF Endpoints (3 new): - GET/PUT shops/{id}/settings — shop features config, hours, days - GET reports/top-products — bestselling products by quantity + revenue PosDataService (3 new methods): - GetShopSettings, UpdateShopSettings, GetTopProducts ShopPage UI (149 lines): - Settings tab: opening hours, business days, features config form - Reports tab: top products table alongside revenue report - Enhanced default placeholder for sections without DB tables --- .../Pages/Admin/Shop/ShopPage.razor | 155 +++++++++++++----- .../Services/PosDataService.cs | 31 ++++ .../Controllers/BffDataController.cs | 96 +++++++++++ 3 files changed, 244 insertions(+), 38 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index a8af198a..d24642d3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -926,25 +926,26 @@ } - @if (_reportProducts.Any()) + @* ─── Top products from real order_items data ─── *@ + @if (_topProducts.Any()) {
-

Top sản phẩm

+

Top sản phẩm bán chạy

- - - + + + - @{ var rank = 1; } - @foreach (var p in _reportProducts.OrderByDescending(x => x.Price).Take(10)) + @{ var tpRank = 1; } + @foreach (var tp in _topProducts) { - - - - + + + + }
#Sản phẩmGiáLoạiTên SPĐã bánDoanh thu
@(rank++)@p.Name@FormatVND(p.Price)@(p.Type ?? "—")@(tpRank++)@(tp.ProductName ?? "—")@tp.TotalSold@FormatVND(tp.TotalRevenue)
@@ -1269,6 +1270,7 @@ // ═══ SETTINGS ═══ case "settings": + @* ─── Shop info (read-only) ─── *@

Thông tin cửa hàng

@@ -1278,39 +1280,55 @@
+ @* ─── Opening hours + business days ─── *@
-

Giờ hoạt động

-
-
-
08:00
-
22:00
-
-
-
-
-

Thuế & Phí

-
-
-
10%
-
5%
-
15,000₫
-
-
-
-
-

🧾 Cài đặt hóa đơn / Bill

+

Giờ & ngày hoạt động

-
-
+
+ + +
+
+ + +
-
-
80mm
-
-
+
+ +
+ @foreach (var (day, code) in new[] { ("T2","Mon"),("T3","Tue"),("T4","Wed"),("T5","Thu"),("T6","Fri"),("T7","Sat"),("CN","Sun") }) + { + var isOn = _settingsOpenDays.Contains(code); + + } +
+ @* ─── Features config JSON ─── *@ +
+

Cấu hình tính năng (JSON)

+
+ +

Nhập JSON để bật/tắt tính năng cho cửa hàng này.

+
+
+ @* ─── Save button ─── *@ +
+ + @if (_settingsMessage != null) + { + @_settingsMessage + } +
break; // ═══ C2: RECIPES / NGUYÊN LIỆU (Café) ═══ @@ -1713,7 +1731,12 @@

@_sectionTitle

-

@_sectionDescription

+

Tính năng đang phát triển

+

@_sectionDescription

+
+ + Sẽ ra mắt trong phiên bản tiếp theo +
break; @@ -1833,6 +1856,16 @@ // Revenue report state private string _reportPeriod = "daily"; private List _revenueReport = new(); + // Settings state + private PosDataService.ShopSettingsInfo? _shopSettings; + private string _settingsOpenTime = ""; + private string _settingsCloseTime = ""; + private string _settingsOpenDays = ""; + private string _settingsFeaturesConfig = "{}"; + private string? _settingsMessage; + private bool _settingsSuccess; + // Top products state + private List _topProducts = new(); protected override async Task OnInitializedAsync() => await LoadData(); @@ -1919,6 +1952,11 @@ case "reports": _reportOrders = await DataService.GetOrdersAsync(_shopGuid); _reportProducts = await DataService.GetAllProductsAsync(_shopGuid); + _topProducts = await DataService.GetTopProductsAsync(_shopGuid); + break; + case "settings": + if (_shopGuid.HasValue) + await LoadShopSettings(); break; case "promotions": _campaigns = await DataService.GetCampaignsAsync(); @@ -1936,6 +1974,47 @@ finally { IsLoading = false; } } + private async Task LoadShopSettings() + { + if (!_shopGuid.HasValue) return; + try + { + _shopSettings = await DataService.GetShopSettingsAsync(_shopGuid.Value); + if (_shopSettings != null) + { + _settingsOpenTime = _shopSettings.OpenTime ?? ""; + _settingsCloseTime = _shopSettings.CloseTime ?? ""; + _settingsOpenDays = _shopSettings.OpenDays ?? ""; + _settingsFeaturesConfig= string.IsNullOrWhiteSpace(_shopSettings.FeaturesConfig) ? "{}" : _shopSettings.FeaturesConfig; + } + } + catch { /* non-fatal */ } + } + + private async Task SaveShopSettings() + { + if (!_shopGuid.HasValue) return; + _settingsMessage = null; + var req = new PosDataService.UpdateShopSettingsRequest( + FeaturesConfig: _settingsFeaturesConfig, + OpenTime: string.IsNullOrWhiteSpace(_settingsOpenTime) ? null : _settingsOpenTime, + CloseTime: string.IsNullOrWhiteSpace(_settingsCloseTime) ? null : _settingsCloseTime, + OpenDays: _settingsOpenDays); + var ok = await DataService.UpdateShopSettingsAsync(_shopGuid.Value, req); + _settingsSuccess = ok; + _settingsMessage = ok ? "Đã lưu thiết lập thành công!" : "Lỗi khi lưu thiết lập."; + StateHasChanged(); + } + + private void ToggleDay(string code) + { + var days = _settingsOpenDays.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); + if (days.Contains(code)) days.Remove(code); + else days.Add(code); + _settingsOpenDays = string.Join(",", days); + StateHasChanged(); + } + private void ConfigureSection() { switch (_section) 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 2a49f173..fc5cfc70 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 @@ -437,4 +437,35 @@ public class PosDataService : $"api/bff/reports/revenue?period={period}"; return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); } + + // ═══ SHOP SETTINGS ═══ + + public record ShopSettingsInfo(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); + public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); + + public async Task GetShopSettingsAsync(Guid shopId) + { + AttachToken(); + return await _http.GetFromJsonAsync($"api/bff/shops/{shopId}/settings", _jsonOptions); + } + + public async Task UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + // ═══ TOP PRODUCTS ═══ + + public record TopProductInfo(string? ProductName, long TotalSold, decimal TotalRevenue); + + public async Task> GetTopProductsAsync(Guid? shopId = null, int limit = 10) + { + AttachToken(); + var url = shopId.HasValue + ? $"api/bff/reports/top-products?shopId={shopId}&limit={limit}" + : $"api/bff/reports/top-products?limit={limit}"; + return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? 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 ee8aa645..520c9991 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 @@ -1406,6 +1406,100 @@ public class BffDataController : ControllerBase catch { return Ok(Array.Empty()); } } + // ═══ C1: SHOP SETTINGS ═══ + + [HttpGet("shops/{shopId:guid}/settings")] + public async Task GetShopSettings(Guid shopId) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return NotFound(); + + await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); + var settings = await conn.QueryFirstOrDefaultAsync( + @"SELECT features_config::text as features_config, + open_time::text as open_time, + close_time::text as close_time, + open_days + FROM shops + WHERE id = @ShopId AND merchant_id = @MerchantId AND is_deleted = false", + new { ShopId = shopId, MerchantId = merchantId }); + + if (settings == null) return NotFound(); + return Ok(settings); + } + + [HttpPut("shops/{shopId:guid}/settings")] + public async Task UpdateShopSettings(Guid shopId, [FromBody] UpdateShopSettingsRequest req) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Contains(shopId)) return Forbid(); + + await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); + try + { + await conn.ExecuteAsync( + @"UPDATE shops + SET features_config = @FeaturesConfig::jsonb, + open_time = @OpenTime::time, + close_time = @CloseTime::time, + open_days = @OpenDays, + updated_at = NOW() + WHERE id = @ShopId AND id = ANY(@ShopIds)", + new + { + ShopId = shopId, + ShopIds = myShopIds.ToArray(), + FeaturesConfig = string.IsNullOrWhiteSpace(req.FeaturesConfig) ? "{}" : req.FeaturesConfig, + OpenTime = string.IsNullOrWhiteSpace(req.OpenTime) ? (object)DBNull.Value : req.OpenTime, + CloseTime= string.IsNullOrWhiteSpace(req.CloseTime) ? (object)DBNull.Value : req.CloseTime, + OpenDays = req.OpenDays + }); + return Ok(new { success = true }); + } + catch (Exception ex) { return BadRequest(new { error = ex.Message }); } + } + + // ═══ C2: TOP PRODUCTS ═══ + + [HttpGet("reports/top-products")] + public async Task GetTopProducts( + [FromQuery] Guid? shopId = null, + [FromQuery] int limit = 10) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) return Ok(Array.Empty()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + + await using var conn = new NpgsqlConnection(ConnStr("order_service")); + try + { + var rows = await conn.QueryAsync( + @"SELECT oi.product_name, + SUM(oi.quantity)::bigint AS total_sold, + SUM(oi.quantity * oi.unit_price)::numeric AS total_revenue + FROM order_items oi + JOIN orders o ON oi.order_id = o.id + WHERE o.shop_id = ANY(@ShopIds) + AND o.status_id IN (3, 5) + GROUP BY oi.product_name + ORDER BY total_sold DESC + LIMIT @Limit", + new { ShopIds = targetShopIds.ToArray(), Limit = limit }); + return Ok(rows); + } + catch { return Ok(Array.Empty()); } + } + // 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); @@ -1417,4 +1511,6 @@ public class BffDataController : ControllerBase public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate); public record CreateMemberRequest(string? Gender, string? CountryCode); public record UpdateMemberRequest(string? Gender, string? Preferences); + public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); + public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue); }